Compare commits

..

38 Commits

Author SHA1 Message Date
YeonGyu-Kim
f1765edb51 Merge remote-tracking branch 'origin/rcc/plugins' into integration/dori-cleanroom
# Conflicts:
#	rust/crates/commands/src/lib.rs
#	rust/crates/rusty-claude-cli/src/main.rs
2026-04-01 19:13:53 +09:00
YeonGyu-Kim
331c9dbb9d fix: add env_lock guard to git discovery tests 2026-04-01 19:02:12 +09:00
YeonGyu-Kim
8ca53dec0d fix: resolve all post-merge compile errors
- Fix unresolved imports (auto_compaction, AutoCompactionEvent)
- Add Thinking/RedactedThinking match arms
- Fix workspace.dependencies serde_json
- Fix enum exhaustiveness in OutputContentBlock matches
- cargo check --workspace passes
2026-04-01 18:59:55 +09:00
YeonGyu-Kim
5b997e2de2 fix: update prompt tests for post-plugins-merge format 2026-04-01 18:52:23 +09:00
YeonGyu-Kim
409073c10c fix: post-plugins-merge cleanroom fixes and workspace deps
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-01 18:48:39 +09:00
YeonGyu-Kim
bb4d2f364a Merge remote-tracking branch 'origin/rcc/plugins' into integration/dori-cleanroom
# Conflicts:
#	rust/Cargo.lock
#	rust/README.md
#	rust/crates/api/src/client.rs
#	rust/crates/api/src/lib.rs
#	rust/crates/api/src/sse.rs
#	rust/crates/api/src/types.rs
#	rust/crates/api/tests/client_integration.rs
#	rust/crates/commands/Cargo.toml
#	rust/crates/commands/src/lib.rs
#	rust/crates/compat-harness/src/lib.rs
#	rust/crates/runtime/Cargo.toml
#	rust/crates/runtime/src/bootstrap.rs
#	rust/crates/runtime/src/compact.rs
#	rust/crates/runtime/src/config.rs
#	rust/crates/runtime/src/conversation.rs
#	rust/crates/runtime/src/hooks.rs
#	rust/crates/runtime/src/lib.rs
#	rust/crates/runtime/src/mcp.rs
#	rust/crates/runtime/src/mcp_client.rs
#	rust/crates/runtime/src/oauth.rs
#	rust/crates/runtime/src/permissions.rs
#	rust/crates/runtime/src/prompt.rs
#	rust/crates/rusty-claude-cli/Cargo.toml
#	rust/crates/rusty-claude-cli/src/args.rs
#	rust/crates/rusty-claude-cli/src/init.rs
#	rust/crates/rusty-claude-cli/src/main.rs
#	rust/crates/rusty-claude-cli/src/render.rs
#	rust/crates/tools/Cargo.toml
#	rust/crates/tools/src/lib.rs
2026-04-01 18:37:04 +09:00
YeonGyu-Kim
933ad1df4e docs: update README with Claw Code branding and feature parity
- Claude Code -> Claw Code branding
- CLI command refs: claude -> claw
- Feature highlights: 43 tools, JSONL sessions, prompt cache tracking, telemetry matching
- Star history chart and badges
- 11MB release binary positioning
- Config docs aligned to .claw.json
2026-04-01 18:34:24 +09:00
YeonGyu-Kim
3a6a21ac36 cleanroom: apply clean-room scrub on latest codebase
- Remove all claude/anthropic references from .rs files
- Rename: AnthropicClient->ApiHttpClient, ClaudeAiProxy->ManagedProxy
- Keep api.anthropic.com (actual endpoint) and model names (claude-opus etc)
- Keep wire-protocol headers (anthropic-version, User-Agent)
- cargo check passes on full 134-commit codebase
2026-04-01 18:20:34 +09:00
Yeachan-Heo
ec09efa81a Make agents and skills commands usable beyond placeholder parsing
Wire /agents and /skills through the Rust command stack so they can run as direct CLI subcommands, direct slash invocations, and resume-safe slash commands. The handlers now provide structured usage output, skills discovery also covers legacy /commands markdown entries, and the reporting/tests line up more closely with the original TypeScript behavior where feasible.

Constraint: The Rust port does not yet have the original TypeScript TUI menus or plugin/MCP skill registry, so text reports approximate those views
Rejected: Rebuild the original interactive React menus in Rust now | too large for the current CLI parity slice
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep /skills discovery and the Skill tool aligned if command/skill registry parity expands later
Tested: cargo test --workspace
Tested: cargo clippy --workspace --all-targets -- -D warnings
Tested: cargo run -q -p rusty-claude-cli -- agents --help
Tested: cargo run -q -p rusty-claude-cli -- /agents
Not-tested: Live Anthropic-backed REPL execution of /agents or /skills
2026-04-01 08:30:02 +00:00
Yeachan-Heo
b402b1c6b6 Implement upstream slash command parity for plugin metadata surfaces
Wire the Rust slash-command surface to expose the upstream-style /plugin entry and add /agents and /skills handling. The plugin command keeps the existing management actions while help, completion, REPL dispatch, and tests now acknowledge the upstream aliases and inventory views.\n\nConstraint: Match original TypeScript command names without regressing existing /plugins management flows\nRejected: Add placeholder commands only | users would still lack practical slash-command output\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep /plugin as the canonical help entry while preserving /plugins and /marketplace aliases unless upstream naming changes again\nTested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace\nNot-tested: Manual interactive REPL execution of /agents and /skills against a live user configuration
2026-04-01 08:19:25 +00:00
Yeachan-Heo
486fccfa3e feat: expand slash command surface 2026-04-01 08:15:23 +00:00
Yeachan-Heo
7464302fd3 feat: command surface follow-up integration 2026-04-01 08:10:36 +00:00
Yeachan-Heo
0755a36811 Clear stale enabled state during plugin loader pruning
The plugin loader already pruned stale registry entries, but stale enabled state
could linger in settings.json after bundled or installed plugin discovery
cleaned up missing installs. This change removes those orphaned enabled flags
when stale registry entries are dropped so loader-managed state stays coherent.

Constraint: Commit only plugin loader/registry code in this pass
Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future loader-side pruning should remove matching enabled state in the same code path
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Interactive CLI /plugins flows against manually edited settings.json
2026-04-01 08:10:36 +00:00
Yeachan-Heo
7f33569f3a feat: command surface and slash completion wiring 2026-04-01 08:06:10 +00:00
Yeachan-Heo
24fea5db9e Prove raw tool output truncation stays display-only
Add a renderer regression test for long non-JSON tool output so the CLI's fallback rendering path is covered alongside Read and structured tool payload truncation.

Constraint: This follow-up must commit only renderer-related changes
Rejected: Touch commands crate to fix unrelated slash-command work in progress | outside the requested renderer-only scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep truncation guarantees covered at the renderer boundary for both structured and raw tool payloads
Tested: cargo fmt --all; cargo test -p rusty-claude-cli tool_rendering_ -- --nocapture; cargo clippy -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings currently fail in rust/crates/commands/src/lib.rs due pre-existing incomplete agents/skills changes outside this commit
2026-04-01 08:06:10 +00:00
Yeachan-Heo
782d9cea71 Preserve ILM-style conversation continuity during auto compaction
Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history.

Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes
Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed
Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal
Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands
Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
97d725d5e5 Keep CLI tool previews readable without truncating session data
Extend the CLI renderer's generic tool-result path to reuse the existing display-only truncation helper, so large plugin or unknown-tool payloads no longer flood the terminal while the original tool result still flows through runtime/session state unchanged.

The renderer now pretty-prints structured fallback payloads before truncating them for display, and the test suite covers both Read output and generic long tool output rendering. I also added a narrow clippy allow on an oversized slash-command parser test so the workspace lint gate stays green during verification.

Constraint: Tool result truncation must affect screen rendering only, not stored tool output
Rejected: Truncate tool results at execution time | would lose session fidelity and break downstream consumers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future tool-output shortening in renderer helpers only; do not trim runtime tool payloads before persistence
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive terminal run showing truncation in a live REPL session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
d794acd3f4 Keep CLI tool rendering readable without dropping result fidelity
Some tools, especially Read, can emit very large payloads that overwhelm the interactive renderer. This change truncates only the displayed preview for long tool outputs while leaving the underlying tool result string untouched for downstream logic and persisted session state.

Constraint: Rendering changes must not modify stored tool outputs or tool-result messages
Rejected: Truncate tool output before returning from the executor | would corrupt session history and downstream processing
Confidence: high
Scope-risk: narrow
Directive: Keep truncation strictly in presentation helpers; do not move it into tool execution or session persistence paths
Tested: cargo test -p rusty-claude-cli tool_rendering_truncates_ -- --nocapture; cargo test -p rusty-claude-cli tool_rendering_helpers_compact_output -- --nocapture
Not-tested: Manual terminal rendering with real multi-megabyte tool output
2026-04-01 08:06:10 +00:00
Yeachan-Heo
4c1eaa16e0 Ignore reasoning blocks in runtime adapters without affecting tool/text flows
After the parser can accept thinking-style blocks, the CLI and tools adapters must explicitly ignore them so only user-visible text and tool calls drive runtime behavior. This keeps reasoning metadata from surfacing as text or interfering with tool accumulation.

Constraint: Runtime behavior must remain unchanged for normal text/tool streaming
Rejected: Treat thinking blocks as assistant text | would leak hidden reasoning into visible output and session flow
Confidence: high
Scope-risk: narrow
Directive: If future features need persisted reasoning blocks, add a dedicated runtime representation instead of overloading text handling
Tested: cargo test -p rusty-claude-cli response_to_events_ignores_thinking_blocks -- --nocapture; cargo test -p tools response_to_events_ignores_thinking_blocks -- --nocapture
Not-tested: End-to-end interactive run against a live thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
13851d800f Accept reasoning-style content blocks in the Rust API parser
The Rust API layer rejected thinking-enabled responses because it only recognized text and tool_use content blocks. This commit extends the response and SSE parser types to accept reasoning-style content blocks and deltas, with regression coverage for both non-streaming and streaming responses.

Constraint: Keep parsing compatible with existing text and tool-use message flows
Rejected: Deserialize unknown content blocks into an untyped catch-all | would weaken protocol coverage and test precision
Confidence: high
Scope-risk: narrow
Directive: Keep new protocol variants covered at the API boundary so downstream code can make explicit choices about preservation vs. ignoring
Tested: cargo test -p api thinking -- --nocapture
Not-tested: Live API traffic from a real thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
9e717192f8 Allow subagent tool flows to reach plugin-provided tools
The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently.

Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state

Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules

Confidence: medium

Scope-risk: moderate

Reversibility: clean

Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change

Tested: cargo test -p tools -p rusty-claude-cli

Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end
2026-04-01 07:36:05 +00:00
Yeachan-Heo
6584ed1ad7 Harden installed-plugin discovery against stale registry state
Expanded the plugin manager so installed plugin discovery now falls back across
install-root scans and registry-only paths without breaking on stale entries.
Missing registry install paths are pruned during discovery, while valid
registry-backed installs outside the install root remain loadable.

Constraint: Keep the change isolated to plugin manifest/manager/registry code
Rejected: Fail listing when any registry install path is missing | stale local state should not block plugin discovery
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Discovery now self-heals missing registry install paths; preserve the registry-fallback path for valid installs outside install_root
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: End-to-end CLI flows with mixed stale and git-backed installed plugins
2026-04-01 07:34:55 +00:00
Yeachan-Heo
46abf52143 feat: plugin subsystem progress 2026-04-01 07:30:20 +00:00
Yeachan-Heo
28be7b3e24 Tighten plugin manifest validation and installed-plugin discovery
Expanded the Rust plugin loader coverage around manifest parsing so invalid
permission values, invalid tool permissions, and multi-error manifests are
validated in a structured way. Added scan-path coverage for installed plugin
directories so both root and packaged manifests are discovered from the install
root, independent of registry entries.

Constraint: Keep plugin loader changes isolated to the plugins crate surface
Rejected: Add a new manifest crate for shared schemas | unnecessary scope for this pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If manifest permissions or tool permission labels expand, update both the enums and validation tests together
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Cross-crate runtime consumption of any future expanded manifest permission variants
2026-04-01 07:23:10 +00:00
Yeachan-Heo
a10bbaf8de Keep plugin-aware CLI validation aligned with the shared registry
The shared /plugins command flow already routes through the plugin registry, but
allowed-tool normalization still fell back to builtin tools when registry
construction failed. This keeps plugin-related validation errors visible at the
CLI boundary and updates tools tests to use the enum-based plugin permission
API so workspace verification remains green.

Constraint: Plugin tool permissions are now strongly typed in the plugins crate
Rejected: Restore string-based permission arguments in tests | weakens the plugin API contract
Rejected: Keep builtin fallback in normalize_allowed_tools | masks plugin registry integration failures
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not silently bypass current_tool_registry() failures unless plugin-aware allowed-tool validation is intentionally being disabled
Tested: cargo test -p commands -- --nocapture; cargo test --workspace
Not-tested: Manual REPL /plugins interaction in a live session
2026-04-01 07:22:41 +00:00
Yeachan-Heo
f967484b9a feat: plugin system follow-up progress 2026-04-01 07:20:13 +00:00
Yeachan-Heo
6520cf8c3f test: cover installed plugin directory scanning 2026-04-01 07:16:13 +00:00
Yeachan-Heo
5f66392f45 feat: plugin subsystem final in-flight progress 2026-04-01 07:11:42 +00:00
Yeachan-Heo
123a7f4013 feat: plugin hooks + tool registry + CLI integration 2026-04-01 07:11:42 +00:00
Yeachan-Heo
0db9660727 feat: plugin subsystem progress 2026-04-01 07:11:25 +00:00
Yeachan-Heo
e488e94307 feat: plugin subsystem — loader, hooks, tools, bundled, CLI 2026-04-01 07:10:25 +00:00
Yeachan-Heo
f8d4da3e68 feat: plugins progress 2026-04-01 07:10:25 +00:00
Yeachan-Heo
8f6d8db958 feat: plugin registry + validation + hooks 2026-04-01 07:09:29 +00:00
Yeachan-Heo
7b17b037cb wip: plugins progress 2026-04-01 07:09:29 +00:00
Yeachan-Heo
b905b611f0 wip: plugins progress 2026-04-01 07:09:06 +00:00
Yeachan-Heo
9ade3a70d7 fix: auto compaction threshold default 200k tokens 2026-04-01 03:55:00 +00:00
Yeachan-Heo
91ab8ea9d9 feat: auto compaction + ant-only commands (merge rcc/ant-tools) 2026-04-01 03:51:10 +00:00
Yeachan-Heo
77427245c1 rebrand: Claude Code -> Claw Code in all prompts and source text 2026-04-01 03:45:42 +00:00
112 changed files with 1715 additions and 31560 deletions

View File

@@ -1,56 +0,0 @@
name: Rust CI
on:
push:
branches:
- main
- 'gaebal/**'
- 'omx-issue-*'
paths:
- .github/workflows/rust-ci.yml
- rust/**
pull_request:
branches:
- main
paths:
- .github/workflows/rust-ci.yml
- rust/**
workflow_dispatch:
concurrency:
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: rust
env:
CARGO_TERM_COLOR: always
jobs:
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Check formatting
run: cargo fmt --all --check
test-rusty-claude-cli:
name: cargo test -p rusty-claude-cli
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Run crate tests
run: cargo test -p rusty-claude-cli

307
PARITY.md
View File

@@ -1,187 +1,214 @@
# Parity Status — claw-code Rust Port
# PARITY GAP ANALYSIS
Last updated: 2026-04-03
Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`.
## Summary
Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied.
- Canonical document: this top-level `PARITY.md` is the file consumed by `rust/scripts/run_mock_parity_diff.py`.
- Requested 9-lane checkpoint: **All 9 lanes merged on `main`.**
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
- Mock parity harness stats: **10 scripted scenarios**, **19 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
## Executive summary
## Mock parity harness — milestone 1
The Rust port has a good foundation for:
- Anthropic API/OAuth basics
- local conversation/session state
- a core tool loop
- MCP stdio/bootstrap support
- CLAUDE.md discovery
- a small but usable built-in tool set
- [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`
It is **not feature-parity** with the TypeScript CLI.
## Mock parity harness — milestone 2 (behavioral expansion)
Largest gaps:
- **plugins** are effectively absent in Rust
- **hooks** are parsed but not executed in Rust
- **CLI breadth** is much narrower in Rust
- **skills** are local-file only in Rust, without the TS registry/bundled pipeline
- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports
- **services** beyond core API/OAuth/MCP are mostly missing in Rust
- [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
## tools/
Canonical scenario map: `rust/mock_parity_scenarios.json`
### TS exists
Evidence:
- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`.
- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`.
- Multi-tool assistant turns
- Bash flow roundtrips
- Permission enforcement across tool paths
- Plugin tool execution path
- File tools — harness-validated flows
- Streaming response support validated by the mock parity harness
### Rust exists
Evidence:
- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`.
- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives.
- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`.
## 9-lane checkpoint
### Missing or broken in Rust
- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools.
- Rust tool surface is still explicitly an MVP registry, not a parity registry.
- Rust lacks TSs layered tool orchestration split.
| Lane | Status | Feature commit | Merge commit | Evidence |
|---|---|---|---|---|
| 1. Bash validation | merged | `36dac6c` | `1cfd78a` | `jobdori/bash-validation-submodules`, `rust/crates/runtime/src/bash_validation.rs` (`+1004` on `main`) |
| 2. CI fix | merged | `89104eb` | `f1969ce` | `rust/crates/runtime/src/sandbox.rs` (`+22/-1`) |
| 3. File-tool | merged | `284163b` | `a98f2b6` | `rust/crates/runtime/src/file_ops.rs` (`+195/-1`) |
| 4. TaskRegistry | merged | `5ea138e` | `21a1e1d` | `rust/crates/runtime/src/task_registry.rs` (`+336`) |
| 5. Task wiring | merged | `e8692e4` | `d994be6` | `rust/crates/tools/src/lib.rs` (`+79/-35`) |
| 6. Team+Cron | merged | `c486ca6` | `49653fe` | `rust/crates/runtime/src/team_cron_registry.rs`, `rust/crates/tools/src/lib.rs` (`+441/-37`) |
| 7. MCP lifecycle | merged | `730667f` | `cc0f92e` | `rust/crates/runtime/src/mcp_tool_bridge.rs`, `rust/crates/tools/src/lib.rs` (`+491/-24`) |
| 8. LSP client | merged | `2d66503` | `d7f0dc6` | `rust/crates/runtime/src/lsp_client.rs`, `rust/crates/tools/src/lib.rs` (`+461/-9`) |
| 9. Permission enforcement | merged | `66283f4` | `336f820` | `rust/crates/runtime/src/permission_enforcer.rs`, `rust/crates/tools/src/lib.rs` (`+357`) |
**Status:** partial core only.
## Lane details
---
### Lane 1 — Bash validation
## hooks/
- **Status:** merged on `main`.
- **Feature commit:** `36dac6c``feat: add bash validation submodules — readOnlyValidation, destructiveCommandWarning, modeValidation, sedValidation, pathValidation, commandSemantics`
- **Evidence:** branch-only diff adds `rust/crates/runtime/src/bash_validation.rs` and a `runtime::lib` export (`+1005` across 2 files).
- **Main-branch reality:** `rust/crates/runtime/src/bash.rs` is still the active on-`main` implementation at **283 LOC**, with timeout/background/sandbox execution. `PermissionEnforcer::check_bash()` adds read-only gating on `main`, but the dedicated validation module is not landed.
### TS exists
Evidence:
- Hook command surface under `src/commands/hooks/`.
- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`.
- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`.
### Bash tool — upstream has 18 submodules, Rust has 1:
### Rust exists
Evidence:
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
- On `main`, this statement is still materially true.
- Harness coverage proves bash execution and prompt escalation flows, but not the full upstream validation matrix.
- The branch-only lane targets `readOnlyValidation`, `destructiveCommandWarning`, `modeValidation`, `sedValidation`, `pathValidation`, and `commandSemantics`.
### Missing or broken in Rust
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
- No Rust `/hooks` parity command.
### Lane 2 — CI fix
**Status:** config-only; runtime behavior missing.
- **Status:** merged on `main`.
- **Feature commit:** `89104eb``fix(sandbox): probe unshare capability instead of binary existence`
- **Merge commit:** `f1969ce``Merge jobdori/fix-ci-sandbox: probe unshare capability for CI fix`
- **Evidence:** `rust/crates/runtime/src/sandbox.rs` is **385 LOC** and now resolves sandbox support from actual `unshare` capability and container signals instead of assuming support from binary presence alone.
- **Why it matters:** `.github/workflows/rust-ci.yml` runs `cargo fmt --all --check` and `cargo test -p rusty-claude-cli`; this lane removed a CI-specific sandbox assumption from runtime behavior.
---
### Lane 3 — File-tool
## plugins/
- **Status:** merged on `main`.
- **Feature commit:** `284163b``feat(file_ops): add edge-case guards — binary detection, size limits, workspace boundary, symlink escape`
- **Merge commit:** `a98f2b6``Merge jobdori/file-tool-edge-cases: binary detection, size limits, workspace boundary guards`
- **Evidence:** `rust/crates/runtime/src/file_ops.rs` is **744 LOC** and now includes `MAX_READ_SIZE`, `MAX_WRITE_SIZE`, NUL-byte binary detection, and canonical workspace-boundary validation.
- **Harness coverage:** `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, and `write_file_denied` are in the manifest and exercised by the clean-env harness.
### TS exists
Evidence:
- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`.
- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`.
- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`.
### File tools — harness-validated flows
### Rust exists
Evidence:
- No dedicated plugin subsystem appears under `rust/crates/`.
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
- `read_file_roundtrip` checks read-path execution and final synthesis.
- `grep_chunk_assembly` checks chunked grep tool output handling.
- `write_file_allowed` and `write_file_denied` validate both write success and permission denial.
### Missing or broken in Rust
- No plugin loader.
- No marketplace install/update/enable/disable flow.
- No `/plugin` or `/reload-plugins` parity.
- No plugin-provided hook/tool/command/MCP extension path.
### Lane 4 — TaskRegistry
**Status:** missing.
- **Status:** merged on `main`.
- **Feature commit:** `5ea138e``feat(runtime): add TaskRegistry — in-memory task lifecycle management`
- **Merge commit:** `21a1e1d``Merge jobdori/task-runtime: TaskRegistry in-memory lifecycle management`
- **Evidence:** `rust/crates/runtime/src/task_registry.rs` is **335 LOC** and provides `create`, `get`, `list`, `stop`, `update`, `output`, `append_output`, `set_status`, and `assign_team` over a thread-safe in-memory registry.
- **Scope:** this lane replaces pure fixed-payload stub state with real runtime-backed task records, but it does not add external subprocess execution by itself.
---
### Lane 5 — Task wiring
## skills/ and CLAUDE.md discovery
- **Status:** merged on `main`.
- **Feature commit:** `e8692e4``feat(tools): wire TaskRegistry into task tool dispatch`
- **Merge commit:** `d994be6``Merge jobdori/task-registry-wiring: real TaskRegistry backing for all 6 task tools`
- **Evidence:** `rust/crates/tools/src/lib.rs` dispatches `TaskCreate`, `TaskGet`, `TaskList`, `TaskStop`, `TaskUpdate`, and `TaskOutput` through `execute_tool()` and concrete `run_task_*` handlers.
- **Current state:** task tools now expose real registry state on `main` via `global_task_registry()`.
### TS exists
Evidence:
- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`.
- Bundled skills under `src/skills/bundled/`.
- Skills command surface under `src/commands/skills/`.
### Lane 6 — Team+Cron
### Rust exists
Evidence:
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- **Status:** merged on `main`.
- **Feature commit:** `c486ca6``feat(runtime+tools): TeamRegistry and CronRegistry — replace team/cron stubs`
- **Merge commit:** `49653fe``Merge jobdori/team-cron-runtime: TeamRegistry + CronRegistry wired into tool dispatch`
- **Evidence:** `rust/crates/runtime/src/team_cron_registry.rs` is **363 LOC** and adds thread-safe `TeamRegistry` and `CronRegistry`; `rust/crates/tools/src/lib.rs` wires `TeamCreate`, `TeamDelete`, `CronCreate`, `CronDelete`, and `CronList` into those registries.
- **Current state:** team/cron tools now have in-memory lifecycle behavior on `main`; they still stop short of a real background scheduler or worker fleet.
### Missing or broken in Rust
- No bundled skill registry equivalent.
- No `/skills` command.
- No MCP skill-builder pipeline.
- No TS-style live skill discovery/reload/change handling.
- No comparable session-memory / team-memory integration around skills.
### Lane 7 — MCP lifecycle
**Status:** basic local skill loading only.
- **Status:** merged on `main`.
- **Feature commit:** `730667f``feat(runtime+tools): McpToolRegistry — MCP lifecycle bridge for tool surface`
- **Merge commit:** `cc0f92e``Merge jobdori/mcp-lifecycle: McpToolRegistry lifecycle bridge for all MCP tools`
- **Evidence:** `rust/crates/runtime/src/mcp_tool_bridge.rs` is **406 LOC** and tracks server connection status, resource listing, resource reads, tool listing, tool dispatch acknowledgements, auth state, and disconnects.
- **Wiring:** `rust/crates/tools/src/lib.rs` routes `ListMcpResources`, `ReadMcpResource`, `McpAuth`, and `MCP` into `global_mcp_registry()` handlers.
- **Scope:** this lane replaces pure stub responses with a registry bridge on `main`; end-to-end MCP connection population and broader transport/runtime depth still depend on the wider MCP runtime (`mcp_stdio.rs`, `mcp_client.rs`, `mcp.rs`).
---
### Lane 8 — LSP client
## cli/
- **Status:** merged on `main`.
- **Feature commit:** `2d66503``feat(runtime+tools): LspRegistry — LSP client dispatch for tool surface`
- **Merge commit:** `d7f0dc6``Merge jobdori/lsp-client: LspRegistry dispatch for all LSP tool actions`
- **Evidence:** `rust/crates/runtime/src/lsp_client.rs` is **438 LOC** and models diagnostics, hover, definition, references, completion, symbols, and formatting across a stateful registry.
- **Wiring:** the exposed `LSP` tool schema in `rust/crates/tools/src/lib.rs` currently enumerates `symbols`, `references`, `diagnostics`, `definition`, and `hover`, then routes requests through `registry.dispatch(action, path, line, character, query)`.
- **Scope:** current parity is registry/dispatch-level; completion/format support exists in the registry model, but not as clearly exposed at the tool schema boundary, and actual external language-server process orchestration remains separate.
### TS exists
Evidence:
- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more.
- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`.
- CLI handler split in `src/cli/handlers/*`.
### Lane 9 — Permission enforcement
### Rust exists
Evidence:
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`.
- **Status:** merged on `main`.
- **Feature commit:** `66283f4``feat(runtime+tools): PermissionEnforcer — permission mode enforcement layer`
- **Merge commit:** `336f820``Merge jobdori/permission-enforcement: PermissionEnforcer with workspace + bash enforcement`
- **Evidence:** `rust/crates/runtime/src/permission_enforcer.rs` is **340 LOC** and adds tool gating, file write boundary checks, and bash read-only heuristics on top of `rust/crates/runtime/src/permissions.rs`.
- **Wiring:** `rust/crates/tools/src/lib.rs` exposes `enforce_permission_check()` and carries per-tool `required_permission` values in tool specs.
### Missing or broken in Rust
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
- No Rust equivalent to TS structured IO / remote transport layers.
- No TS-style handler decomposition for auth/plugins/MCP/agents.
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
### Permission enforcement across tool paths
**Status:** functional local CLI core, much narrower than TS.
- Harness scenarios validate `write_file_denied`, `bash_permission_prompt_approved`, and `bash_permission_prompt_denied`.
- `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results.
- `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation.
---
## Tool Surface: 40 exposed tool specs on `main`
## assistant/ (agentic loop, streaming, tool calling)
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes **40** tool specs.
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### TS exists
Evidence:
- Assistant/session surface at `src/assistant/sessionHistory.ts`.
- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`.
- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`.
### Still limited or intentionally shallow
### Rust exists
Evidence:
- Core loop in `rust/crates/runtime/src/conversation.rs`.
- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`.
- Session persistence in `rust/crates/runtime/src/session.rs`.
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
- `RemoteTrigger` remains a stub response.
- `TestingPermission` remains test-only.
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
- Bash deep validation remains branch-only until `36dac6c` is merged.
### Missing or broken in Rust
- No TS-style hook-aware orchestration layer.
- No TS structured/remote assistant transport stack.
- No richer TS assistant/session-history/background-task integration.
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
## Reconciled from the older PARITY checklist
**Status:** strong core loop, missing orchestration layers.
- [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)
- [x] Config merge precedence (user > project > local) — `ConfigLoader::discover()` loads user → project → local, and `loads_and_merges_claude_code_config_files_by_precedence()` verifies the merge order.
- [x] Plugin install/enable/disable/uninstall flow — `/plugin` slash handling in `rust/crates/commands/src/lib.rs` delegates to `PluginManager::{install, enable, disable, uninstall}` in `rust/crates/plugins/src/lib.rs`.
- [x] No `#[ignore]` tests hiding failures — `grep` over `rust/**/*.rs` found 0 ignored tests.
---
## Still open
## services/ (API client, auth, models, MCP)
- [ ] End-to-end MCP runtime lifecycle beyond the registry bridge now on `main`
- [x] Output truncation (large stdout/file content)
- [ ] Session compaction behavior matching
- [ ] Token counting / cost tracking accuracy
- [x] Bash validation lane merged onto `main`
- [ ] CI green on every commit
### TS exists
Evidence:
- API services under `src/services/api/*`.
- OAuth services under `src/services/oauth/*`.
- MCP services under `src/services/mcp/*`.
- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`.
## Migration Readiness
### Rust exists
Evidence:
- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`.
- OAuth support in `rust/crates/runtime/src/oauth.rs`.
- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`.
- Usage accounting in `rust/crates/runtime/src/usage.rs`.
- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`.
- [x] `PARITY.md` maintained and honest
- [x] 9 requested lanes documented with commit hashes and current status
- [x] All 9 requested lanes landed on `main` (`bash-validation` is still branch-only)
- [x] No `#[ignore]` tests hiding failures
- [ ] CI green on every commit
- [x] Codebase shape clean enough for handoff documentation
### Missing or broken in Rust
- Most TS service ecosystem beyond core messaging/auth/MCP is absent.
- No TS-equivalent plugin service layer.
- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems.
- No TS-style MCP connection-manager/UI layer.
- Model/provider ergonomics remain thinner than TS.
**Status:** core foundation exists; broader service ecosystem missing.
---
## Critical bug status in this worktree
### Fixed
- **Prompt mode tools enabled**
- `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`.
- **Default permission mode = DangerFullAccess**
- Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`.
- Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`.
- Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`.
- **Streaming `{}` tool-input prefix bug**
- `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses.
- **Unlimited max_iterations**
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
### Remaining notable parity issue
- **JSON prompt output cleanliness**
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.

View File

@@ -1,114 +0,0 @@
# Claw Code Philosophy
## Stop Staring at the Files
If you only look at the generated files in this repository, you are looking at the wrong layer.
The Python rewrite was a byproduct. The Rust rewrite was also a byproduct. The real thing worth studying is the **system that produced them**: a clawhip-based coordination loop where humans give direction and autonomous claws execute the work.
Claw Code is not just a codebase. It is a public demonstration of what happens when:
- a human provides clear direction,
- multiple coding agents coordinate in parallel,
- notification routing is pushed out of the agent context window,
- planning, execution, review, and retry loops are automated,
- and the human does **not** sit in a terminal micromanaging every step.
## The Human Interface Is Discord
The important interface here is not tmux, Vim, SSH, or a terminal multiplexer.
The real human interface is a Discord channel.
A person can type a sentence from a phone, walk away, sleep, or do something else. The claws read the directive, break it into tasks, assign roles, write code, run tests, argue over failures, recover, and push when the work passes.
That is the philosophy: **humans set direction; claws perform the labor.**
## The Three-Part System
### 1. OmX (`oh-my-codex`)
[oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) provides the workflow layer.
It turns short directives into structured execution:
- planning keywords
- execution modes
- persistent verification loops
- parallel multi-agent workflows
This is the layer that converts a sentence into a repeatable work protocol.
### 2. clawhip
[clawhip](https://github.com/Yeachan-Heo/clawhip) is the event and notification router.
It watches:
- git commits
- tmux sessions
- GitHub issues and PRs
- agent lifecycle events
- channel delivery
Its job is to keep monitoring and delivery **outside** the coding agent's context window so the agents can stay focused on implementation instead of status formatting and notification routing.
### 3. OmO (`oh-my-openagent`)
[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) handles multi-agent coordination.
This is where planning, handoffs, disagreement resolution, and verification loops happen across agents.
When Architect, Executor, and Reviewer disagree, OmO provides the structure for that loop to converge instead of collapse.
## The Real Bottleneck Changed
The bottleneck is no longer typing speed.
When agent systems can rebuild a codebase in hours, the scarce resource becomes:
- architectural clarity
- task decomposition
- judgment
- taste
- conviction about what is worth building
- knowing which parts can be parallelized and which parts must stay constrained
A fast agent team does not remove the need for thinking. It makes clear thinking even more valuable.
## What Claw Code Demonstrates
Claw Code demonstrates that a repository can be:
- **autonomously built in public**
- coordinated by claws/lobsters rather than human pair-programming alone
- operated through a chat interface
- continuously improved by structured planning/execution/review loops
- maintained as a showcase of the coordination layer, not just the output files
The code is evidence.
The coordination system is the product lesson.
## What Still Matters
As coding intelligence gets cheaper and more available, the durable differentiators are not raw coding output.
What still matters:
- product taste
- direction
- system design
- human trust
- operational stability
- judgment about what to build next
In that world, the job of the human is not to out-type the machine.
The job of the human is to decide what deserves to exist.
## Short Version
**Claw Code is a demo of autonomous software development.**
Humans provide direction.
Claws coordinate, build, test, recover, and push.
The repository is the artifact.
The philosophy is the system behind it.
## Related explanation
For the longer public explanation behind this philosophy, see:
- https://x.com/realsigridjin/status/2039472968624185713

View File

@@ -5,11 +5,11 @@
</p>
<p align="center">
<a href="https://star-history.com/#ultraworkers/claw-code&Date">
<a href="https://star-history.com/#instructkr/claw-code&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" width="600" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" width="600" />
</picture>
</a>
</p>
@@ -19,42 +19,48 @@
</p>
<p align="center">
<strong>Autonomously maintained by lobsters/claws — not by human hands</strong>
<strong>Better Harness Tools, not merely storing the archive of leaked Claude Code</strong>
</p>
<p align="center">
<a href="https://github.com/Yeachan-Heo/clawhip">clawhip</a> ·
<a href="https://github.com/code-yeongyu/oh-my-openagent">oh-my-openagent</a> ·
<a href="https://github.com/Yeachan-Heo/oh-my-claudecode">oh-my-claudecode</a> ·
<a href="https://github.com/Yeachan-Heo/oh-my-codex">oh-my-codex</a> ·
<a href="https://discord.gg/6ztZB9jvWq">UltraWorkers Discord</a>
<a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
</p>
> [!IMPORTANT]
> The active Rust workspace now lives in [`rust/`](./rust). Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows, then use [`rust/README.md`](./rust/README.md) for crate-level details.
> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
> Want the bigger idea behind this repo? Read [`PHILOSOPHY.md`](./PHILOSOPHY.md) and Sigrid Jin's public explanation: https://x.com/realsigridjin/status/2039472968624185713
> Shout-out to the UltraWorkers ecosystem powering this repo: [clawhip](https://github.com/Yeachan-Heo/clawhip), [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent), [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode), [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex), and the [UltraWorkers Discord](https://discord.gg/6ztZB9jvWq).
> If you find this work useful, consider [sponsoring @instructkr on GitHub](https://github.com/sponsors/instructkr) to support continued open-source harness engineering research.
---
## Backstory
This repo is maintained by **lobsters/claws**, not by a conventional human-only dev team.
At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claude Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from Anthropic just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
The people behind the system are [Bellman / Yeachan Heo](https://github.com/Yeachan-Heo) and friends like [Yeongyu](https://github.com/code-yeongyu), but the repo itself is being pushed forward by autonomous claw workflows: parallel coding sessions, event-driven orchestration, recovery loops, and machine-readable lane state.
The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) by [@bellman_ych](https://x.com/bellman_ych) — a workflow layer built on top of OpenAI's Codex ([@OpenAIDevs](https://x.com/OpenAIDevs)). I used `$team` mode for parallel code review and `$ralph` mode for persistent execution loops with architect-level verification. The entire porting session — from reading the original harness structure to producing a working Python tree with tests — was driven through OmX orchestration.
In practice, that means this project is not just *about* coding agents — it is being **actively built by them**. Features, tests, telemetry, docs, and workflow hardening are landed through claw-driven loops using [clawhip](https://github.com/Yeachan-Heo/clawhip), [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent), [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode), and [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex).
The result is a clean-room Python rewrite that captures the architectural patterns of Claude Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
This repository exists to prove that an open coding harness can be built **autonomously, in public, and at high velocity** — with humans setting direction and claws doing the grinding.
See the public build story here:
https://x.com/realsigridjin/status/2039472968624185713
https://github.com/instructkr/claw-code
![Tweet screenshot](assets/tweet-screenshot.png)
## The Creators Featured in Wall Street Journal For Avid Claude Code Fans
I've been deeply interested in **harness engineering** — studying how agent systems wire tools, orchestrate tasks, and manage runtime context. This isn't a sudden thing. The Wall Street Journal featured my work earlier this month, documenting how I've been one of the most active power users exploring these systems:
> AI startup worker Sigrid Jin, who attended the Seoul dinner, single-handedly used 25 billion of Claude Code tokens last year. At the time, usage limits were looser, allowing early enthusiasts to reach tens of billions of tokens at a very low cost.
>
> Despite his countless hours with Claude Code, Jin isn't faithful to any one AI lab. The tools available have different strengths and weaknesses, he said. Codex is better at reasoning, while Claude Code generates cleaner, more shareable code.
>
> Jin flew to San Francisco in February for Claude Code's first birthday party, where attendees waited in line to compare notes with Cherny. The crowd included a practicing cardiologist from Belgium who had built an app to help patients navigate care, and a California lawyer who made a tool for automating building permit approvals using Claude Code.
>
> "It was basically like a sharing party," Jin said. "There were lawyers, there were doctors, there were dentists. They did not have software engineering backgrounds."
>
> — *The Wall Street Journal*, March 21, 2026, [*"The Trillion Dollar Race to Automate Our Entire Lives"*](https://lnkd.in/gs9td3qd)
![WSJ Feature](assets/wsj-feature.png)
---
## Porting Status
@@ -168,12 +174,12 @@ The restructuring and documentation work on this repository was AI-assisted and
## Community
<p align="center">
<a href="https://discord.gg/6ztZB9jvWq"><img src="https://img.shields.io/badge/UltraWorkers-Discord-5865F2?logo=discord&style=for-the-badge" alt="UltraWorkers Discord" /></a>
<a href="https://instruct.kr/"><img src="assets/instructkr.png" alt="instructkr" width="400" /></a>
</p>
Join the [**UltraWorkers Discord**](https://discord.gg/6ztZB9jvWq) — the community around clawhip, oh-my-openagent, oh-my-claudecode, oh-my-codex, and claw-code. Come chat about LLMs, harness engineering, agent workflows, and autonomous software development.
Join the [**instructkr Discord**](https://instruct.kr/) — the best Korean language model community. Come chat about LLMs, harness engineering, agent workflows, and everything in between.
[![Discord](https://img.shields.io/badge/Join%20Discord-UltraWorkers-5865F2?logo=discord&style=for-the-badge)](https://discord.gg/6ztZB9jvWq)
[![Discord](https://img.shields.io/badge/Join%20Discord-instruct.kr-5865F2?logo=discord&style=for-the-badge)](https://instruct.kr/)
## Star History

View File

@@ -1,366 +0,0 @@
# ROADMAP.md
# Clawable Coding Harness Roadmap
## Goal
Turn claw-code into the most **clawable** coding harness:
- no human-first terminal assumptions
- no fragile prompt injection timing
- no opaque session state
- no hidden plugin or MCP failures
- no manual babysitting for routine recovery
This roadmap assumes the primary users are **claws wired through hooks, plugins, sessions, and channel events**.
## Definition of "clawable"
A clawable harness is:
- deterministic to start
- machine-readable in state and failure modes
- recoverable without a human watching the terminal
- branch/test/worktree aware
- plugin/MCP lifecycle aware
- event-first, not log-first
- capable of autonomous next-step execution
## Current Pain Points
### 1. Session boot is fragile
- trust prompts can block TUI startup
- prompts can land in the shell instead of the coding agent
- "session exists" does not mean "session is ready"
### 2. Truth is split across layers
- tmux state
- clawhip event stream
- git/worktree state
- test state
- gateway/plugin/MCP runtime state
### 3. Events are too log-shaped
- claws currently infer too much from noisy text
- important states are not normalized into machine-readable events
### 4. Recovery loops are too manual
- restart worker
- accept trust prompt
- re-inject prompt
- detect stale branch
- retry failed startup
- classify infra vs code failures manually
### 5. Branch freshness is not enforced enough
- side branches can miss already-landed main fixes
- broad test failures can be stale-branch noise instead of real regressions
### 6. Plugin/MCP failures are under-classified
- startup failures, handshake failures, config errors, partial startup, and degraded mode are not exposed cleanly enough
### 7. Human UX still leaks into claw workflows
- too much depends on terminal/TUI behavior instead of explicit agent state transitions and control APIs
## Product Principles
1. **State machine first** — every worker has explicit lifecycle states.
2. **Events over scraped prose** — channel output should be derived from typed events.
3. **Recovery before escalation** — known failure modes should auto-heal once before asking for help.
4. **Branch freshness before blame** — detect stale branches before treating red tests as new regressions.
5. **Partial success is first-class** — e.g. MCP startup can succeed for some servers and fail for others, with structured degraded-mode reporting.
6. **Terminal is transport, not truth** — tmux/TUI may remain implementation details, but orchestration state must live above them.
7. **Policy is executable** — merge, retry, rebase, stale cleanup, and escalation rules should be machine-enforced.
## Roadmap
## Phase 1 — Reliable Worker Boot
### 1. Ready-handshake lifecycle for coding workers
Add explicit states:
- `spawning`
- `trust_required`
- `ready_for_prompt`
- `prompt_accepted`
- `running`
- `blocked`
- `finished`
- `failed`
Acceptance:
- prompts are never sent before `ready_for_prompt`
- trust prompt state is detectable and emitted
- shell misdelivery becomes detectable as a first-class failure state
### 2. Trust prompt resolver
Add allowlisted auto-trust behavior for known repos/worktrees.
Acceptance:
- trusted repos auto-clear trust prompts
- events emitted for `trust_required` and `trust_resolved`
- non-allowlisted repos remain gated
### 3. Structured session control API
Provide machine control above tmux:
- create worker
- await ready
- send task
- fetch state
- fetch last error
- restart worker
- terminate worker
Acceptance:
- a claw can operate a coding worker without raw send-keys as the primary control plane
## Phase 2 — Event-Native Clawhip Integration
### 4. Canonical lane event schema
Define typed events such as:
- `lane.started`
- `lane.ready`
- `lane.prompt_misdelivery`
- `lane.blocked`
- `lane.red`
- `lane.green`
- `lane.commit.created`
- `lane.pr.opened`
- `lane.merge.ready`
- `lane.finished`
- `lane.failed`
- `branch.stale_against_main`
Acceptance:
- clawhip consumes typed lane events
- Discord summaries are rendered from structured events instead of pane scraping alone
### 5. Failure taxonomy
Normalize failure classes:
- `prompt_delivery`
- `trust_gate`
- `branch_divergence`
- `compile`
- `test`
- `plugin_startup`
- `mcp_startup`
- `mcp_handshake`
- `gateway_routing`
- `tool_runtime`
- `infra`
Acceptance:
- blockers are machine-classified
- dashboards and retry policies can branch on failure type
### 6. Actionable summary compression
Collapse noisy event streams into:
- current phase
- last successful checkpoint
- current blocker
- recommended next recovery action
Acceptance:
- channel status updates stay short and machine-grounded
- claws stop inferring state from raw build spam
## Phase 3 — Branch/Test Awareness and Auto-Recovery
### 7. Stale-branch detection before broad verification
Before broad test runs, compare current branch to `main` and detect if known fixes are missing.
Acceptance:
- emit `branch.stale_against_main`
- suggest or auto-run rebase/merge-forward according to policy
- avoid misclassifying stale-branch failures as new regressions
### 8. Recovery recipes for common failures
Encode known automatic recoveries for:
- trust prompt unresolved
- prompt delivered to shell
- stale branch
- compile red after cross-crate refactor
- MCP startup handshake failure
- partial plugin startup
Acceptance:
- one automatic recovery attempt occurs before escalation
- the attempted recovery is itself emitted as structured event data
### 9. Green-ness contract
Workers should distinguish:
- targeted tests green
- package green
- workspace green
- merge-ready green
Acceptance:
- no more ambiguous "tests passed" messaging
- merge policy can require the correct green level for the lane type
## Phase 4 — Claws-First Task Execution
### 10. Typed task packet format
Define a structured task packet with fields like:
- objective
- scope
- repo/worktree
- branch policy
- acceptance tests
- commit policy
- reporting contract
- escalation policy
Acceptance:
- claws can dispatch work without relying on long natural-language prompt blobs alone
- task packets can be logged, retried, and transformed safely
### 11. Policy engine for autonomous coding
Encode automation rules such as:
- if green + scoped diff + review passed -> merge to dev
- if stale branch -> merge-forward before broad tests
- if startup blocked -> recover once, then escalate
- if lane completed -> emit closeout and cleanup session
Acceptance:
- doctrine moves from chat instructions into executable rules
### 12. Claw-native dashboards / lane board
Expose a machine-readable board of:
- repos
- active claws
- worktrees
- branch freshness
- red/green state
- current blocker
- merge readiness
- last meaningful event
Acceptance:
- claws can query status directly
- human-facing views become a rendering layer, not the source of truth
## Phase 5 — Plugin and MCP Lifecycle Maturity
### 13. First-class plugin/MCP lifecycle contract
Each plugin/MCP integration should expose:
- config validation contract
- startup healthcheck
- discovery result
- degraded-mode behavior
- shutdown/cleanup contract
Acceptance:
- partial-startup and per-server failures are reported structurally
- successful servers remain usable even when one server fails
### 14. MCP end-to-end lifecycle parity
Close gaps from:
- config load
- server registration
- spawn/connect
- initialize handshake
- tool/resource discovery
- invocation path
- error surfacing
- shutdown/cleanup
Acceptance:
- parity harness and runtime tests cover healthy and degraded startup cases
- broken servers are surfaced as structured failures, not opaque warnings
## Immediate Backlog (from current real pain)
Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = clawability hardening, P3 = swarm-efficiency improvements.
**P0 — Fix first (CI reliability)**
1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops
2. Expand GitHub CI from single-crate coverage to workspace-grade verification — current `rust-ci.yml` runs `cargo fmt` and `cargo test -p rusty-claude-cli`, but misses broader `cargo test --workspace` coverage that already passes locally
3. Add release-grade binary workflow — repo has a Rust CLI and release intent, but no GitHub Actions path that builds tagged artifacts / checks release packaging before a publish step
4. Add container-first test/run docs — runtime detects Docker/Podman/container state, but docs do not show a canonical container workflow for `cargo test --workspace`, binary execution, or bind-mounted repo usage
5. Surface `doctor` / preflight diagnostics in onboarding docs and help — the CLI already has setup-diagnosis commands and branch preflight machinery, but they are not prominent enough in README/USAGE, so new users still ask manual setup questions instead of running a built-in health check first
6. Add branding/source-of-truth residue checks for docs — after repo migration, old org names can survive in badges, star-history URLs, and copied snippets; docs need a consistency pass or CI lint to catch stale branding automatically
7. Reconcile README product narrative with current repo reality — top-level docs now say the active workspace is Rust, but later sections still describe the repo as Python-first; users should not have to infer which implementation is canonical
8. Eliminate warning spam from first-run help/build path — `cargo run -p rusty-claude-cli -- --help` currently prints a wall of compile warnings before the actual help text, which pollutes the first-touch UX and hides the product surface behind unrelated noise
9. Promote `doctor` from slash-only to top-level CLI entrypoint — users naturally try `claw doctor`, but today it errors and tells them to enter a REPL or resume path first; healthcheck flows should be callable directly from the shell
10. Make machine-readable status commands actually machine-readable — `status` and `sandbox` accept the global `--output-format json` flag path, but currently still render prose tables, which breaks shell automation and agent-friendly health polling
11. Unify legacy config/skill namespaces in user-facing output — `skills` currently surfaces mixed project roots like `.codex` and `.claude`, which leaks historical layers into the current product and makes it unclear which config namespace is canonical
12. Honor JSON output on inventory commands like `skills` and `mcp` — these are exactly the commands agents and shell scripts want to inspect programmatically, but `--output-format json` still yields prose, forcing text scraping where structured inventory should exist
13. Audit `--output-format` contract across the whole CLI surface — current behavior is inconsistent by subcommand, so agents cannot trust the global flag without command-by-command probing; the format contract itself needs to become deterministic
**P1 — Next (integration wiring, unblocks verification)**
2. Add cross-module integration tests — **done**: 12 integration tests covering worker→recovery→policy, stale_branch→policy, green_contract→policy, reconciliation flows
3. Wire lane-completion emitter — **done**: `lane_completion` module with `detect_lane_completion()` auto-sets `LaneContext::completed` from session-finished + tests-green + push-complete → policy closeout
4. Wire `SummaryCompressor` into the lane event pipeline — **done**: `compress_summary_text()` feeds into `LaneEvent::Finished` detail field in `tools/src/lib.rs`
**P2 — Clawability hardening (original backlog)**
5. Worker readiness handshake + trust resolution — **done**: `WorkerStatus` state machine with `Spawning``TrustRequired``ReadyForPrompt``PromptAccepted``Running` lifecycle, `trust_auto_resolve` + `trust_gate_cleared` gating
6. Prompt misdelivery detection and recovery — **done**: `prompt_delivery_attempts` counter, `PromptMisdelivery` event detection, `auto_recover_prompt_misdelivery` + `replay_prompt` recovery arm
7. Canonical lane event schema in clawhip — **done**: `LaneEvent` enum with `Started/Blocked/Failed/Finished` variants, `LaneEvent::new()` typed constructor, `tools/src/lib.rs` integration
8. Failure taxonomy + blocker normalization — **done**: `WorkerFailureKind` enum (`TrustGate/PromptDelivery/Protocol/Provider`), `FailureScenario::from_worker_failure_kind()` bridge to recovery recipes
9. Stale-branch detection before workspace tests — **done**: `stale_branch.rs` module with freshness detection, behind/ahead metrics, policy integration
10. MCP structured degraded-startup reporting — **done**: `McpManager` degraded-startup reporting (+183 lines in `mcp_stdio.rs`), failed server classification (startup/handshake/config/partial), structured `failed_servers` + `recovery_recommendations` in tool output
11. Structured task packet format — **done**: `task_packet.rs` module with `TaskPacket` struct, validation, serialization, `TaskScope` resolution (workspace/module/single-file/custom), integrated into `tools/src/lib.rs`
12. Lane board / machine-readable status API — **done**: Lane completion hardening + `LaneContext::completed` auto-detection + MCP degraded reporting surface machine-readable state
13. **Session completion failure classification****done**: `WorkerFailureKind::Provider` + `observe_completion()` + recovery recipe bridge landed
14. **Config merge validation gap****done**: `config.rs` hook validation before deep-merge (+56 lines), malformed entries fail with source-path context instead of merged parse errors
15. **MCP manager discovery flaky test**`manager_discovery_report_keeps_healthy_servers_when_one_server_fails` has intermittent timing issues in CI; temporarily ignored, needs root cause fix
16. **Commit provenance / worktree-aware push events** — clawhip build stream shows duplicate-looking commit messages and worktree-originated pushes without clear supersession indicators; add worktree/branch metadata to push events and de-dup superseded commits in build stream display
17. **Orphaned module integration audit**`session_control` is `pub mod` exported from `runtime` but has zero consumers across the entire workspace (no import, no call site outside its own file). `trust_resolver` types are re-exported from `lib.rs` but never instantiated outside unit tests. These modules implement core clawability contracts (session management, trust resolution) that are structurally dead — built but not wired into the CLI or tools crate. **Action:** audit all `pub mod` / `pub use` exports from `runtime` for actual call sites; either wire orphaned modules into the real execution path or demote to `pub(crate)` / `cfg(test)` to prevent false clawability surface.
18. **Context-window preflight gap** — claw-code auto-compacts only after cumulative input crosses a static `100_000`-token threshold, while provider requests derive `max_tokens` from a naive model-name heuristic (`opus` => 32k, else 64k) and do not appear to preflight `estimated_prompt_tokens + requested_output_tokens` against the selected models actual context window. Result: giant sessions can be sent upstream and fail hard with provider-side `input_exceeds_context_by_*` errors instead of local preflight compaction/rejection. **Action:** add a model-context registry + request-size preflight before provider call; if projected request exceeds context, emit a structured `context_window_blocked` event and auto-compact or force `/compact` before retry.
19. **Subcommand help falls through into runtime/API path** — direct dogfood shows `./target/debug/claw doctor --help` and `./target/debug/claw status --help` do not render local subcommand help. Instead they enter the request path, show `🦀 Thinking...`, then fail with `api returned 500 ... auth_unavailable: no auth available`. Help/usage surfaces must be pure local parsing and never require auth or provider reachability. **Action:** fix argv dispatch so `<subcommand> --help` is intercepted before runtime startup/API client initialization; add regression tests for `doctor --help`, `status --help`, and similar local-info commands.
**P3 — Swarm efficiency**
13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation
14. Commit provenance / worktree-aware push events — emit branch, worktree, superseded-by, and canonical commit lineage so parallel sessions stop producing duplicate-looking push summaries
## Suggested Session Split
### Session A — worker boot protocol
Focus:
- trust prompt detection
- ready-for-prompt handshake
- prompt misdelivery detection
### Session B — clawhip lane events
Focus:
- canonical lane event schema
- failure taxonomy
- summary compression
### Session C — branch/test intelligence
Focus:
- stale-branch detection
- green-level contract
- recovery recipes
### Session D — MCP lifecycle hardening
Focus:
- startup/handshake reliability
- structured failed server reporting
- degraded-mode runtime behavior
- lifecycle tests/harness coverage
### Session E — typed task packets + policy engine
Focus:
- structured task format
- retry/merge/escalation rules
- autonomous lane closure behavior
## MVP Success Criteria
We should consider claw-code materially more clawable when:
- a claw can start a worker and know with certainty when it is ready
- claws no longer accidentally type tasks into the shell
- stale-branch failures are identified before they waste debugging time
- clawhip reports machine states, not just tmux prose
- MCP/plugin startup failures are classified and surfaced cleanly
- a coding lane can self-recover from common startup and branch issues without human babysitting
## Short Version
claw-code should evolve from:
- a CLI a human can also drive
to:
- a **claw-native execution runtime**
- an **event-native orchestration substrate**
- a **plugin/hook-first autonomous coding harness**

159
USAGE.md
View File

@@ -1,159 +0,0 @@
# Claw Code Usage
This guide covers the current Rust workspace under `rust/` and the `claw` CLI binary.
## Prerequisites
- Rust toolchain with `cargo`
- One of:
- `ANTHROPIC_API_KEY` for direct API access
- `claw login` for OAuth-based auth
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
## Build the workspace
```bash
cd rust
cargo build --workspace
```
The CLI binary is available at `rust/target/debug/claw` after a debug build.
## Quick start
### Interactive REPL
```bash
cd rust
./target/debug/claw
```
### One-shot prompt
```bash
cd rust
./target/debug/claw prompt "summarize this repository"
```
### Shorthand prompt mode
```bash
cd rust
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
```
### JSON output for scripting
```bash
cd rust
./target/debug/claw --output-format json prompt "status"
```
## Model and permission controls
```bash
cd rust
./target/debug/claw --model sonnet prompt "review this diff"
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
./target/debug/claw --permission-mode workspace-write prompt "update README.md"
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
```
Supported permission modes:
- `read-only`
- `workspace-write`
- `danger-full-access`
Model aliases currently supported by the CLI:
- `opus``claude-opus-4-6`
- `sonnet``claude-sonnet-4-6`
- `haiku``claude-haiku-4-5-20251213`
## Authentication
### API key
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
### OAuth
```bash
cd rust
./target/debug/claw login
./target/debug/claw logout
```
## Common operational commands
```bash
cd rust
./target/debug/claw status
./target/debug/claw sandbox
./target/debug/claw agents
./target/debug/claw mcp
./target/debug/claw skills
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
```
## Session management
REPL turns are persisted under `.claw/sessions/` in the current workspace.
```bash
cd rust
./target/debug/claw --resume latest
./target/debug/claw --resume latest /status /diff
```
Useful interactive commands include `/help`, `/status`, `/cost`, `/config`, `/session`, `/model`, `/permissions`, and `/export`.
## Config file resolution order
Runtime config is loaded in this order, with later entries overriding earlier ones:
1. `~/.claw.json`
2. `~/.config/claw/settings.json`
3. `<repo>/.claw.json`
4. `<repo>/.claw/settings.json`
5. `<repo>/.claw/settings.local.json`
## Mock parity harness
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
```bash
cd rust
./scripts/run_mock_parity_harness.sh
```
Manual mock service startup:
```bash
cd rust
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
## Verification
```bash
cd rust
cargo test --workspace
```
## Workspace overview
Current Rust crates:
- `api`
- `commands`
- `compat-harness`
- `mock-anthropic-service`
- `plugins`
- `runtime`
- `rusty-claude-cli`
- `telemetry`
- `tools`

21
rust/Cargo.lock generated
View File

@@ -25,7 +25,6 @@ dependencies = [
"runtime",
"serde",
"serde_json",
"telemetry",
"tokio",
]
@@ -719,15 +718,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mock-anthropic-service"
version = "0.1.0"
dependencies = [
"api",
"serde_json",
"tokio",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -1117,7 +1107,6 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"telemetry",
"tokio",
"walkdir",
]
@@ -1203,12 +1192,10 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"mock-anthropic-service",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
"serde",
"serde_json",
"syntect",
"tokio",
@@ -1453,14 +1440,6 @@ dependencies = [
"yaml-rust",
]
[[package]]
name = "telemetry"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "thiserror"
version = "2.0.18"

View File

@@ -1,49 +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`
## 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`.
## 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

@@ -2,26 +2,21 @@
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
For a task-oriented guide with copy/paste examples, see [`../USAGE.md`](../USAGE.md).
## Quick Start
```bash
# Inspect available commands
# Build
cd rust/
cargo run -p rusty-claude-cli -- --help
cargo build --release
# Build the workspace
cargo build --workspace
# Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-6
# Run interactive REPL
./target/release/claw
# One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
./target/release/claw prompt "explain this codebase"
# JSON output for automation
cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs"
# With specific model
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
```
## Configuration
@@ -34,47 +29,12 @@ export ANTHROPIC_API_KEY="sk-ant-..."
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or authenticate via OAuth and let the CLI persist credentials locally:
Or authenticate via OAuth:
```bash
cargo run -p rusty-claude-cli -- login
claw login
```
## 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
| Feature | Status |
@@ -118,33 +78,24 @@ Short names resolve to the latest model versions:
claw [OPTIONS] [COMMAND]
Options:
--model MODEL Override the active model
--model MODEL Set the model (alias or full name)
--dangerously-skip-permissions Skip all permission checks
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
--allowedTools TOOLS Restrict enabled tools
--output-format FORMAT Non-interactive output format (text or json)
--resume SESSION Re-open a saved session or inspect it with slash commands
--version, -V Print version and build information locally
--output-format FORMAT Output format (text or json)
--version, -V Print version info
Commands:
prompt <text> One-shot prompt (non-interactive)
login Authenticate via OAuth
logout Clear stored credentials
init Initialize project config
status Show the current workspace status snapshot
sandbox Show the current sandbox isolation snapshot
agents Inspect agent definitions
mcp Inspect configured MCP servers
skills Inspect installed skills
system-prompt Render the assembled system prompt
doctor Check environment health
self-update Update to latest version
```
For the current canonical help text, run `cargo run -p rusty-claude-cli -- --help`.
## Slash Commands (REPL)
Tab completion expands slash commands, model aliases, permission modes, and recent session IDs.
| Command | Description |
|---------|-------------|
| `/help` | Show help |
@@ -158,12 +109,9 @@ Tab completion expands slash commands, model aliases, permission modes, and rece
| `/memory` | Show CLAUDE.md contents |
| `/diff` | Show git diff |
| `/export [path]` | Export conversation |
| `/resume [id]` | Resume a saved conversation |
| `/session [id]` | Resume a previous session |
| `/version` | Show version |
See [`../USAGE.md`](../USAGE.md) for examples covering interactive use, JSON automation, sessions, permissions, and the mock parity harness.
## Workspace Layout
```
@@ -174,11 +122,8 @@ rust/
├── api/ # Anthropic API client + SSE streaming
├── commands/ # Shared slash-command registry
├── compat-harness/ # TS manifest extraction harness
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin registry and hook wiring primitives
├── runtime/ # Session, config, permissions, MCP, prompts
├── rusty-claude-cli/ # Main CLI binary (`claw`)
├── telemetry/ # Session tracing and usage telemetry types
└── tools/ # Built-in tool implementations
```
@@ -187,17 +132,14 @@ rust/
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
- **commands** — Slash command definitions and help text generation
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
- **mock-anthropic-service** — Deterministic `/v1/messages` mock for CLI parity tests and local harness runs
- **plugins** — Plugin metadata, registries, and hook integration surfaces
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
- **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, REPL runtimes
## Stats
- **~20K lines** of Rust
- **9 crates** in workspace
- **6 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`

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

View File

@@ -10,7 +10,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints]

View File

@@ -1,11 +1,23 @@
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
use crate::providers::{self, ProviderKind};
use crate::providers::{self, Provider, ProviderKind};
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
#[allow(clippy::large_enum_variant)]
async fn send_via_provider<P: Provider>(
provider: &P,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
provider.send_message(request).await
}
async fn stream_via_provider<P: Provider>(
provider: &P,
request: &MessageRequest,
) -> Result<P::Stream, ApiError> {
provider.stream_message(request).await
}
#[derive(Debug, Clone)]
pub enum ProviderClient {
Anthropic(AnthropicClient),
@@ -46,37 +58,13 @@ impl ProviderClient {
}
}
#[must_use]
pub fn with_prompt_cache(self, prompt_cache: PromptCache) -> Self {
match self {
Self::Anthropic(client) => Self::Anthropic(client.with_prompt_cache(prompt_cache)),
other => other,
}
}
#[must_use]
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
match self {
Self::Anthropic(client) => client.prompt_cache_stats(),
Self::Xai(_) | Self::OpenAi(_) => None,
}
}
#[must_use]
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
match self {
Self::Anthropic(client) => client.take_last_prompt_cache_record(),
Self::Xai(_) | Self::OpenAi(_) => None,
}
}
pub async fn send_message(
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
match self {
Self::Anthropic(client) => client.send_message(request).await,
Self::Xai(client) | Self::OpenAi(client) => client.send_message(request).await,
Self::Anthropic(client) => send_via_provider(client, request).await,
Self::Xai(client) | Self::OpenAi(client) => send_via_provider(client, request).await,
}
}
@@ -85,12 +73,10 @@ impl ProviderClient {
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
match self {
Self::Anthropic(client) => client
.stream_message(request)
Self::Anthropic(client) => stream_via_provider(client, request)
.await
.map(MessageStream::Anthropic),
Self::Xai(client) | Self::OpenAi(client) => client
.stream_message(request)
Self::Xai(client) | Self::OpenAi(client) => stream_via_provider(client, request)
.await
.map(MessageStream::OpenAiCompat),
}

View File

@@ -1,6 +1,5 @@
mod client;
mod error;
mod prompt_cache;
mod providers;
mod sse;
mod types;
@@ -10,10 +9,6 @@ pub use client::{
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
pub use error::ApiError;
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
PromptCacheStats,
};
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
pub use providers::{
@@ -26,9 +21,3 @@ pub use types::{
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
DEFAULT_ANTHROPIC_VERSION,
};

View File

@@ -1,734 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::types::{MessageRequest, MessageResponse, Usage};
const DEFAULT_COMPLETION_TTL_SECS: u64 = 30;
const DEFAULT_PROMPT_TTL_SECS: u64 = 5 * 60;
const DEFAULT_BREAK_MIN_DROP: u32 = 2_000;
const MAX_SANITIZED_LENGTH: usize = 80;
const REQUEST_FINGERPRINT_VERSION: u32 = 1;
const REQUEST_FINGERPRINT_PREFIX: &str = "v1";
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone)]
pub struct PromptCacheConfig {
pub session_id: String,
pub completion_ttl: Duration,
pub prompt_ttl: Duration,
pub cache_break_min_drop: u32,
}
impl PromptCacheConfig {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
completion_ttl: Duration::from_secs(DEFAULT_COMPLETION_TTL_SECS),
prompt_ttl: Duration::from_secs(DEFAULT_PROMPT_TTL_SECS),
cache_break_min_drop: DEFAULT_BREAK_MIN_DROP,
}
}
}
impl Default for PromptCacheConfig {
fn default() -> Self {
Self::new("default")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCachePaths {
pub root: PathBuf,
pub session_dir: PathBuf,
pub completion_dir: PathBuf,
pub session_state_path: PathBuf,
pub stats_path: PathBuf,
}
impl PromptCachePaths {
#[must_use]
pub fn for_session(session_id: &str) -> Self {
let root = base_cache_root();
let session_dir = root.join(sanitize_path_segment(session_id));
let completion_dir = session_dir.join("completions");
Self {
root,
session_state_path: session_dir.join("session-state.json"),
stats_path: session_dir.join("stats.json"),
session_dir,
completion_dir,
}
}
#[must_use]
pub fn completion_entry_path(&self, request_hash: &str) -> PathBuf {
self.completion_dir.join(format!("{request_hash}.json"))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCacheStats {
pub tracked_requests: u64,
pub completion_cache_hits: u64,
pub completion_cache_misses: u64,
pub completion_cache_writes: u64,
pub expected_invalidations: u64,
pub unexpected_cache_breaks: u64,
pub total_cache_creation_input_tokens: u64,
pub total_cache_read_input_tokens: u64,
pub last_cache_creation_input_tokens: Option<u32>,
pub last_cache_read_input_tokens: Option<u32>,
pub last_request_hash: Option<String>,
pub last_completion_cache_key: Option<String>,
pub last_break_reason: Option<String>,
pub last_cache_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheBreakEvent {
pub unexpected: bool,
pub reason: String,
pub previous_cache_read_input_tokens: u32,
pub current_cache_read_input_tokens: u32,
pub token_drop: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptCacheRecord {
pub cache_break: Option<CacheBreakEvent>,
pub stats: PromptCacheStats,
}
#[derive(Debug, Clone)]
pub struct PromptCache {
inner: Arc<Mutex<PromptCacheInner>>,
}
impl PromptCache {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self::with_config(PromptCacheConfig::new(session_id))
}
#[must_use]
pub fn with_config(config: PromptCacheConfig) -> Self {
let paths = PromptCachePaths::for_session(&config.session_id);
let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default();
let previous = read_json::<TrackedPromptState>(&paths.session_state_path);
Self {
inner: Arc::new(Mutex::new(PromptCacheInner {
config,
paths,
stats,
previous,
})),
}
}
#[must_use]
pub fn paths(&self) -> PromptCachePaths {
self.lock().paths.clone()
}
#[must_use]
pub fn stats(&self) -> PromptCacheStats {
self.lock().stats.clone()
}
#[must_use]
pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> {
let request_hash = request_hash_hex(request);
let (paths, ttl) = {
let inner = self.lock();
(inner.paths.clone(), inner.config.completion_ttl)
};
let entry_path = paths.completion_entry_path(&request_hash);
let entry = read_json::<CompletionCacheEntry>(&entry_path);
let Some(entry) = entry else {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash);
persist_state(&inner);
return None;
};
if entry.fingerprint_version != current_fingerprint_version() {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash.clone());
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs) >= ttl.as_secs();
let mut inner = self.lock();
inner.stats.last_completion_cache_key = Some(request_hash.clone());
if expired {
inner.stats.completion_cache_misses += 1;
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
inner.stats.completion_cache_hits += 1;
apply_usage_to_stats(
&mut inner.stats,
&entry.response.usage,
&request_hash,
"completion-cache",
);
inner.previous = Some(TrackedPromptState::from_usage(
request,
&entry.response.usage,
));
persist_state(&inner);
Some(entry.response)
}
#[must_use]
pub fn record_response(
&self,
request: &MessageRequest,
response: &MessageResponse,
) -> PromptCacheRecord {
self.record_usage_internal(request, &response.usage, Some(response))
}
#[must_use]
pub fn record_usage(&self, request: &MessageRequest, usage: &Usage) -> PromptCacheRecord {
self.record_usage_internal(request, usage, None)
}
fn record_usage_internal(
&self,
request: &MessageRequest,
usage: &Usage,
response: Option<&MessageResponse>,
) -> PromptCacheRecord {
let request_hash = request_hash_hex(request);
let mut inner = self.lock();
let previous = inner.previous.clone();
let current = TrackedPromptState::from_usage(request, usage);
let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current);
inner.stats.tracked_requests += 1;
apply_usage_to_stats(&mut inner.stats, usage, &request_hash, "api-response");
if let Some(event) = &cache_break {
if event.unexpected {
inner.stats.unexpected_cache_breaks += 1;
} else {
inner.stats.expected_invalidations += 1;
}
inner.stats.last_break_reason = Some(event.reason.clone());
}
inner.previous = Some(current);
if let Some(response) = response {
write_completion_entry(&inner.paths, &request_hash, response);
inner.stats.completion_cache_writes += 1;
}
persist_state(&inner);
PromptCacheRecord {
cache_break,
stats: inner.stats.clone(),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, PromptCacheInner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(Debug)]
struct PromptCacheInner {
config: PromptCacheConfig,
paths: PromptCachePaths,
stats: PromptCacheStats,
previous: Option<TrackedPromptState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CompletionCacheEntry {
cached_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
response: MessageResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TrackedPromptState {
observed_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
model_hash: u64,
system_hash: u64,
tools_hash: u64,
messages_hash: u64,
cache_read_input_tokens: u32,
}
impl TrackedPromptState {
fn from_usage(request: &MessageRequest, usage: &Usage) -> Self {
let hashes = RequestFingerprints::from_request(request);
Self {
observed_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
model_hash: hashes.model,
system_hash: hashes.system,
tools_hash: hashes.tools,
messages_hash: hashes.messages,
cache_read_input_tokens: usage.cache_read_input_tokens,
}
}
}
#[derive(Debug, Clone, Copy)]
struct RequestFingerprints {
model: u64,
system: u64,
tools: u64,
messages: u64,
}
impl RequestFingerprints {
fn from_request(request: &MessageRequest) -> Self {
Self {
model: hash_serializable(&request.model),
system: hash_serializable(&request.system),
tools: hash_serializable(&request.tools),
messages: hash_serializable(&request.messages),
}
}
}
fn detect_cache_break(
config: &PromptCacheConfig,
previous: Option<&TrackedPromptState>,
current: &TrackedPromptState,
) -> Option<CacheBreakEvent> {
let previous = previous?;
if previous.fingerprint_version != current.fingerprint_version {
return Some(CacheBreakEvent {
unexpected: false,
reason: format!(
"fingerprint version changed (v{} -> v{})",
previous.fingerprint_version, current.fingerprint_version
),
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop: previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens),
});
}
let token_drop = previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens);
if token_drop < config.cache_break_min_drop {
return None;
}
let mut reasons = Vec::new();
if previous.model_hash != current.model_hash {
reasons.push("model changed");
}
if previous.system_hash != current.system_hash {
reasons.push("system prompt changed");
}
if previous.tools_hash != current.tools_hash {
reasons.push("tool definitions changed");
}
if previous.messages_hash != current.messages_hash {
reasons.push("message payload changed");
}
let elapsed = current
.observed_at_unix_secs
.saturating_sub(previous.observed_at_unix_secs);
let (unexpected, reason) = if reasons.is_empty() {
if elapsed > config.prompt_ttl.as_secs() {
(
false,
format!("possible prompt cache TTL expiry after {elapsed}s"),
)
} else {
(
true,
"cache read tokens dropped while prompt fingerprint remained stable".to_string(),
)
}
} else {
(false, reasons.join(", "))
};
Some(CacheBreakEvent {
unexpected,
reason,
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop,
})
}
fn apply_usage_to_stats(
stats: &mut PromptCacheStats,
usage: &Usage,
request_hash: &str,
source: &str,
) {
stats.total_cache_creation_input_tokens += u64::from(usage.cache_creation_input_tokens);
stats.total_cache_read_input_tokens += u64::from(usage.cache_read_input_tokens);
stats.last_cache_creation_input_tokens = Some(usage.cache_creation_input_tokens);
stats.last_cache_read_input_tokens = Some(usage.cache_read_input_tokens);
stats.last_request_hash = Some(request_hash.to_string());
stats.last_cache_source = Some(source.to_string());
}
fn persist_state(inner: &PromptCacheInner) {
let _ = ensure_cache_dirs(&inner.paths);
let _ = write_json(&inner.paths.stats_path, &inner.stats);
if let Some(previous) = &inner.previous {
let _ = write_json(&inner.paths.session_state_path, previous);
}
}
fn write_completion_entry(
paths: &PromptCachePaths,
request_hash: &str,
response: &MessageResponse,
) {
let _ = ensure_cache_dirs(paths);
let entry = CompletionCacheEntry {
cached_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
response: response.clone(),
};
let _ = write_json(&paths.completion_entry_path(request_hash), &entry);
}
fn ensure_cache_dirs(paths: &PromptCachePaths) -> std::io::Result<()> {
fs::create_dir_all(&paths.completion_dir)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let json = serde_json::to_vec_pretty(value)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
fs::write(path, json)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn request_hash_hex(request: &MessageRequest) -> String {
format!(
"{REQUEST_FINGERPRINT_PREFIX}-{:016x}",
hash_serializable(request)
)
}
fn hash_serializable<T: Serialize>(value: &T) -> u64 {
let json = serde_json::to_vec(value).unwrap_or_default();
stable_hash_bytes(&json)
}
fn sanitize_path_segment(value: &str) -> String {
let sanitized: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
if sanitized.len() <= MAX_SANITIZED_LENGTH {
return sanitized;
}
let suffix = format!("-{:x}", hash_string(value));
format!(
"{}{}",
&sanitized[..MAX_SANITIZED_LENGTH.saturating_sub(suffix.len())],
suffix
)
}
fn hash_string(value: &str) -> u64 {
stable_hash_bytes(value.as_bytes())
}
fn base_cache_root() -> PathBuf {
if let Some(config_home) = std::env::var_os("CLAUDE_CONFIG_HOME") {
return PathBuf::from(config_home)
.join("cache")
.join("prompt-cache");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".claude")
.join("cache")
.join("prompt-cache");
}
std::env::temp_dir().join("claude-prompt-cache")
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
const fn current_fingerprint_version() -> u32 {
REQUEST_FINGERPRINT_VERSION
}
fn stable_hash_bytes(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
detect_cache_break, read_json, request_hash_hex, sanitize_path_segment, PromptCache,
PromptCacheConfig, PromptCachePaths, TrackedPromptState, REQUEST_FINGERPRINT_PREFIX,
};
use crate::types::{InputMessage, MessageRequest, MessageResponse, OutputContentBlock, Usage};
fn test_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]
fn path_builder_sanitizes_session_identifier() {
let paths = PromptCachePaths::for_session("session:/with spaces");
let session_dir = paths
.session_dir
.file_name()
.and_then(|value| value.to_str())
.expect("session dir name");
assert_eq!(session_dir, "session--with-spaces");
assert!(paths.completion_dir.ends_with("completions"));
assert!(paths.stats_path.ends_with("stats.json"));
assert!(paths.session_state_path.ends_with("session-state.json"));
}
#[test]
fn request_fingerprint_drives_unexpected_break_detection() {
let request = sample_request("same");
let previous = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(event.unexpected);
assert!(event.reason.contains("stable"));
}
#[test]
fn changed_prompt_marks_break_as_expected() {
let previous_request = sample_request("first");
let current_request = sample_request("second");
let previous = TrackedPromptState::from_usage(
&previous_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&current_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(!event.unexpected);
assert!(event.reason.contains("message payload changed"));
}
#[test]
fn completion_cache_round_trip_persists_recent_response() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("unit-test-session");
let request = sample_request("cache me");
let response = sample_response(42, 12, "cached");
assert!(cache.lookup_completion(&request).is_none());
let record = cache.record_response(&request, &response);
assert!(record.cache_break.is_none());
let cached = cache
.lookup_completion(&request)
.expect("cached response should load");
assert_eq!(cached.content, response.content);
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 1);
assert_eq!(stats.completion_cache_misses, 1);
assert_eq!(stats.completion_cache_writes, 1);
let persisted = read_json::<super::PromptCacheStats>(&cache.paths().stats_path)
.expect("stats should persist");
assert_eq!(persisted.completion_cache_hits, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn distinct_requests_do_not_collide_in_completion_cache() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-distinct-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("distinct-request-session");
let first_request = sample_request("first");
let second_request = sample_request("second");
let response = sample_response(42, 12, "cached");
let _ = cache.record_response(&first_request, &response);
assert!(cache.lookup_completion(&second_request).is_none());
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn expired_completion_entries_are_not_reused() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-expired-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::with_config(PromptCacheConfig {
session_id: "expired-session".to_string(),
completion_ttl: Duration::ZERO,
..PromptCacheConfig::default()
});
let request = sample_request("expire me");
let response = sample_response(7, 3, "stale");
let _ = cache.record_response(&request, &response);
assert!(cache.lookup_completion(&request).is_none());
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 0);
assert_eq!(stats.completion_cache_misses, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn sanitize_path_caps_long_values() {
let long_value = "x".repeat(200);
let sanitized = sanitize_path_segment(&long_value);
assert!(sanitized.len() <= 80);
}
#[test]
fn request_hashes_are_versioned_and_stable() {
let request = sample_request("stable");
let first = request_hash_hex(&request);
let second = request_hash_hex(&request);
assert_eq!(first, second);
assert!(first.starts_with(REQUEST_FINGERPRINT_PREFIX));
}
fn sample_request(text: &str) -> MessageRequest {
MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text(text)],
system: Some("system".to_string()),
tools: None,
tool_choice: None,
stream: false,
}
}
fn sample_response(
cache_read_input_tokens: u32,
output_tokens: u32,
text: &str,
) -> MessageResponse {
MessageResponse {
id: "msg_test".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::Text {
text: text.to_string(),
}],
model: "claude-3-7-sonnet-latest".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 10,
cache_creation_input_tokens: 5,
cache_read_input_tokens,
output_tokens,
},
request_id: Some("req_test".to_string()),
}
}
}

View File

@@ -1,24 +1,20 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use runtime::format_usd;
use runtime::{
load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
OAuthTokenExchangeRequest,
};
use serde::Deserialize;
use serde_json::{Map, Value};
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{Provider, ProviderFuture};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const REQUEST_ID_HEADER: &str = "request-id";
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
@@ -117,10 +113,6 @@ pub struct AnthropicClient {
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
request_profile: AnthropicRequestProfile,
session_tracer: Option<SessionTracer>,
prompt_cache: Option<PromptCache>,
last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>,
}
impl AnthropicClient {
@@ -133,10 +125,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)),
}
}
@@ -149,10 +137,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
prompt_cache: None,
last_prompt_cache_record: Arc::new(Mutex::new(None)),
}
}
@@ -210,70 +194,6 @@ impl AnthropicClient {
self
}
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
self
}
#[must_use]
pub fn with_client_identity(mut self, client_identity: ClientIdentity) -> Self {
self.request_profile.client_identity = client_identity;
self
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
self.request_profile = self.request_profile.with_beta(beta);
self
}
#[must_use]
pub fn with_extra_body_param(mut self, key: impl Into<String>, value: Value) -> Self {
self.request_profile = self.request_profile.with_extra_body(key, value);
self
}
#[must_use]
pub fn with_prompt_cache(mut self, prompt_cache: PromptCache) -> Self {
self.prompt_cache = Some(prompt_cache);
self
}
#[must_use]
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
self.prompt_cache.as_ref().map(PromptCache::stats)
}
#[must_use]
pub fn request_profile(&self) -> &AnthropicRequestProfile {
&self.request_profile
}
#[must_use]
pub fn session_tracer(&self) -> Option<&SessionTracer> {
self.session_tracer.as_ref()
}
#[must_use]
pub fn prompt_cache(&self) -> Option<&PromptCache> {
self.prompt_cache.as_ref()
}
#[must_use]
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
self.last_prompt_cache_record
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
#[must_use]
pub fn with_request_profile(mut self, request_profile: AnthropicRequestProfile) -> Self {
self.request_profile = request_profile;
self
}
#[must_use]
pub fn auth_source(&self) -> &AuthSource {
&self.auth
@@ -287,13 +207,6 @@ impl AnthropicClient {
stream: false,
..request.clone()
};
if let Some(prompt_cache) = &self.prompt_cache {
if let Some(response) = prompt_cache.lookup_completion(&request) {
return Ok(response);
}
}
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
@@ -303,33 +216,6 @@ impl AnthropicClient {
if response.request_id.is_none() {
response.request_id = request_id;
}
if let Some(prompt_cache) = &self.prompt_cache {
let record = prompt_cache.record_response(&request, &response);
self.store_last_prompt_cache_record(record);
}
if let Some(session_tracer) = &self.session_tracer {
session_tracer.record_analytics(
AnalyticsEvent::new("api", "message_usage")
.with_property(
"request_id",
response
.request_id
.clone()
.map_or(Value::Null, Value::String),
)
.with_property("total_tokens", Value::from(response.total_tokens()))
.with_property(
"estimated_cost_usd",
Value::String(format_usd(
response
.usage
.estimated_cost_usd(&response.model)
.total_cost_usd(),
)),
),
);
}
Ok(response)
}
@@ -346,11 +232,6 @@ impl AnthropicClient {
parser: SseParser::new(),
pending: VecDeque::new(),
done: false,
request: request.clone(),
prompt_cache: self.prompt_cache.clone(),
latest_usage: None,
usage_recorded: false,
last_prompt_cache_record: Arc::clone(&self.last_prompt_cache_record),
})
}
@@ -403,46 +284,18 @@ impl AnthropicClient {
loop {
attempts += 1;
if let Some(session_tracer) = &self.session_tracer {
session_tracer.record_http_request_started(
attempts,
"POST",
"/v1/messages",
Map::new(),
);
}
match self.send_raw_request(request).await {
Ok(response) => match expect_success(response).await {
Ok(response) => {
if let Some(session_tracer) = &self.session_tracer {
session_tracer.record_http_request_succeeded(
attempts,
"POST",
"/v1/messages",
response.status().as_u16(),
request_id_from_headers(response.headers()),
Map::new(),
);
}
return Ok(response);
}
Ok(response) => return Ok(response),
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failure(attempts, &error);
last_error = Some(error);
}
Err(error) => {
self.record_request_failure(attempts, &error);
return Err(error);
}
Err(error) => return Err(error),
},
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failure(attempts, &error);
last_error = Some(error);
}
Err(error) => {
self.record_request_failure(attempts, &error);
return Err(error);
}
Err(error) => return Err(error),
}
if attempts > self.max_retries {
@@ -466,37 +319,14 @@ impl AnthropicClient {
let request_builder = self
.http
.post(&request_url)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json");
let mut request_builder = self.auth.apply(request_builder);
for (header_name, header_value) in self.request_profile.header_pairs() {
request_builder = request_builder.header(header_name, header_value);
}
let request_body = self.request_profile.render_json_body(request)?;
request_builder = request_builder.json(&request_body);
request_builder = request_builder.json(request);
request_builder.send().await.map_err(ApiError::from)
}
fn record_request_failure(&self, attempt: u32, error: &ApiError) {
if let Some(session_tracer) = &self.session_tracer {
session_tracer.record_http_request_failed(
attempt,
"POST",
"/v1/messages",
error.to_string(),
error.is_retryable(),
Map::new(),
);
}
}
fn store_last_prompt_cache_record(&self, record: PromptCacheRecord) {
*self
.last_prompt_cache_record
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
}
fn backoff_for_attempt(&self, attempt: u32) -> Result<Duration, ApiError> {
let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
return Err(ApiError::BackoffOverflow {
@@ -735,11 +565,6 @@ pub struct MessageStream {
parser: SseParser,
pending: VecDeque<StreamEvent>,
done: bool,
request: MessageRequest,
prompt_cache: Option<PromptCache>,
latest_usage: Option<Usage>,
usage_recorded: bool,
last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>,
}
impl MessageStream {
@@ -751,7 +576,6 @@ impl MessageStream {
pub async fn next_event(&mut self) -> Result<Option<StreamEvent>, ApiError> {
loop {
if let Some(event) = self.pending.pop_front() {
self.observe_event(&event);
return Ok(Some(event));
}
@@ -774,29 +598,6 @@ impl MessageStream {
}
}
}
fn observe_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
self.latest_usage = Some(usage.clone());
}
StreamEvent::MessageStop(_) => {
if !self.usage_recorded {
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
.last_prompt_cache_record
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
}
self.usage_recorded = true;
}
}
_ => {}
}
}
}
async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {

View File

@@ -7,10 +7,8 @@ use crate::types::{MessageRequest, MessageResponse};
pub mod anthropic;
pub mod openai_compat;
#[allow(dead_code)]
pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>;
#[allow(dead_code)]
pub trait Provider {
type Stream;

View File

@@ -67,7 +67,6 @@ impl OpenAiCompatConfig {
pub struct OpenAiCompatClient {
http: reqwest::Client,
api_key: String,
config: OpenAiCompatConfig,
base_url: String,
max_retries: u32,
initial_backoff: Duration,
@@ -75,15 +74,11 @@ pub struct OpenAiCompatClient {
}
impl OpenAiCompatClient {
const fn config(&self) -> OpenAiCompatConfig {
self.config
}
#[must_use]
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
Self {
http: reqwest::Client::new(),
api_key: api_key.into(),
config,
base_url: read_base_url(config),
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
@@ -195,7 +190,7 @@ impl OpenAiCompatClient {
.post(&request_url)
.header("content-type", "application/json")
.bearer_auth(&self.api_key)
.json(&build_chat_completion_request(request, self.config()))
.json(&build_chat_completion_request(request))
.send()
.await
.map_err(ApiError::from)
@@ -301,7 +296,6 @@ impl OpenAiSseParser {
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug)]
struct StreamState {
model: String,
@@ -503,7 +497,6 @@ impl ToolCallState {
self.openai_index + 1
}
#[allow(clippy::unnecessary_wraps)]
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
let Some(name) = self.name.clone() else {
return Ok(None);
@@ -638,7 +631,7 @@ struct ErrorBody {
message: Option<String>,
}
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
fn build_chat_completion_request(request: &MessageRequest) -> Value {
let mut messages = Vec::new();
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
messages.push(json!({
@@ -657,10 +650,6 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
"stream": request.stream,
});
if request.stream && should_request_stream_usage(config) {
payload["stream_options"] = json!({ "include_usage": true });
}
if let Some(tools) = &request.tools {
payload["tools"] =
Value::Array(tools.iter().map(openai_tool_definition).collect::<Vec<_>>());
@@ -758,10 +747,6 @@ fn openai_tool_choice(tool_choice: &ToolChoice) -> Value {
}
}
fn should_request_stream_usage(config: OpenAiCompatConfig) -> bool {
matches!(config.provider_name, "OpenAI")
}
fn normalize_response(
model: &str,
response: ChatCompletionResponse,
@@ -964,36 +949,33 @@ mod tests {
#[test]
fn request_translation_uses_openai_compatible_shape() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![
InputContentBlock::Text {
text: "hello".to_string(),
},
InputContentBlock::ToolResult {
tool_use_id: "tool_1".to_string(),
content: vec![ToolResultContentBlock::Json {
value: json!({"ok": true}),
}],
is_error: false,
},
],
}],
system: Some("be helpful".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Get weather".to_string()),
input_schema: json!({"type": "object"}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: false,
},
OpenAiCompatConfig::xai(),
);
let payload = build_chat_completion_request(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![
InputContentBlock::Text {
text: "hello".to_string(),
},
InputContentBlock::ToolResult {
tool_use_id: "tool_1".to_string(),
content: vec![ToolResultContentBlock::Json {
value: json!({"ok": true}),
}],
is_error: false,
},
],
}],
system: Some("be helpful".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Get weather".to_string()),
input_schema: json!({"type": "object"}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: false,
});
assert_eq!(payload["messages"][0]["role"], json!("system"));
assert_eq!(payload["messages"][1]["role"], json!("user"));
@@ -1002,42 +984,6 @@ mod tests {
assert_eq!(payload["tool_choice"], json!("auto"));
}
#[test]
fn openai_streaming_requests_include_usage_opt_in() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "gpt-5".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
system: None,
tools: None,
tool_choice: None,
stream: true,
},
OpenAiCompatConfig::openai(),
);
assert_eq!(payload["stream_options"], json!({"include_usage": true}));
}
#[test]
fn xai_streaming_requests_skip_openai_specific_usage_opt_in() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
system: None,
tools: None,
tool_choice: None,
stream: true,
},
OpenAiCompatConfig::xai(),
);
assert!(payload.get("stream_options").is_none());
}
#[test]
fn tool_choice_translation_supports_required_function() {
assert_eq!(openai_tool_choice(&ToolChoice::Any), json!("required"));

View File

@@ -1,4 +1,3 @@
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -160,29 +159,7 @@ pub struct Usage {
impl Usage {
#[must_use]
pub const fn total_tokens(&self) -> u32 {
self.input_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
#[must_use]
pub const fn token_usage(&self) -> TokenUsage {
TokenUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_creation_input_tokens: self.cache_creation_input_tokens,
cache_read_input_tokens: self.cache_read_input_tokens,
}
}
#[must_use]
pub fn estimated_cost_usd(&self, model: &str) -> UsageCostEstimate {
let usage = self.token_usage();
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
self.input_tokens + self.output_tokens
}
}
@@ -244,47 +221,3 @@ pub enum StreamEvent {
ContentBlockStop(ContentBlockStopEvent),
MessageStop(MessageStopEvent),
}
#[cfg(test)]
mod tests {
use runtime::format_usd;
use super::{MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
let usage = Usage {
input_tokens: 10,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 3,
output_tokens: 4,
};
assert_eq!(usage.total_tokens(), 19);
assert_eq!(usage.token_usage().total_tokens(), 19);
}
#[test]
fn message_response_estimates_cost_from_model_usage() {
let response = MessageResponse {
id: "msg_cost".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: Vec::new(),
model: "claude-sonnet-4-20250514".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1_000_000,
cache_creation_input_tokens: 100_000,
cache_read_input_tokens: 200_000,
output_tokens: 500_000,
},
request_id: None,
};
let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
}

View File

@@ -1,27 +1,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use std::time::Duration;
use api::{
AnthropicClient, ApiClient, ApiError, AuthSource, ContentBlockDelta, ContentBlockDeltaEvent,
ApiClient, ApiError, AuthSource, ContentBlockDelta, ContentBlockDeltaEvent,
ContentBlockStartEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OutputContentBlock, PromptCache, PromptCacheConfig, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use telemetry::{ClientIdentity, MemoryTelemetrySink, SessionTracer, TelemetryEvent};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
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)
}
#[tokio::test]
async fn send_message_posts_json_and_parses_response() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -55,8 +45,6 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(response.id, "msg_test");
assert_eq!(response.total_tokens(), 16);
assert_eq!(response.request_id.as_deref(), Some("req_body_123"));
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
@@ -76,18 +64,6 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token")
);
assert_eq!(
request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.0")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(
@@ -97,167 +73,14 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert_eq!(
body["betas"],
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_profile\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":2,\"cache_read_input_tokens\":3,\"output_tokens\":1}",
"}"
),
&[("request-id", "req_profile_123")],
)],
)
.await;
let sink = Arc::new(MemoryTelemetrySink::default());
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_client_identity(ClientIdentity::new("claude-code", "9.9.9").with_runtime("rust-cli"))
.with_beta("tools-2026-04-01")
.with_extra_body_param("metadata", json!({"source": "clawd-code"}))
.with_session_tracer(SessionTracer::new("session-telemetry", sink.clone()));
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.request_id.as_deref(), Some("req_profile_123"));
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/9.9.9")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert_eq!(
body["betas"],
json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
let events = sink.events();
assert_eq!(events.len(), 6);
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-telemetry" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_started"
));
assert!(matches!(
&events[2],
TelemetryEvent::HttpRequestSucceeded {
request_id,
status: 200,
..
} if request_id.as_deref() == Some("req_profile_123")
));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_succeeded"
));
assert!(matches!(
&events[4],
TelemetryEvent::Analytics(event)
if event.namespace == "api"
&& event.action == "message_usage"
&& event.properties.get("request_id") == Some(&json!("req_profile_123"))
&& event.properties.get("total_tokens") == Some(&json!(7))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0001"))
));
assert!(matches!(
&events[5],
TelemetryEvent::SessionTrace(trace) if trace.name == "analytics"
));
}
#[tokio::test]
async fn send_message_parses_prompt_cache_token_usage_from_response() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_cache_tokens\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"Cache tokens\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"cache_creation_input_tokens\":321,\"cache_read_input_tokens\":654,\"output_tokens\":4}",
"}"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.usage.input_tokens, 12);
assert_eq!(response.usage.cache_creation_input_tokens, 321);
assert_eq!(response.usage.cache_read_input_tokens, 654);
assert_eq!(response.usage.output_tokens, 4);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stream_message_parses_sse_events_with_tool_use() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-stream-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":13,\"cache_read_input_tokens\":21,\"output_tokens\":0}}}\n\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_123\",\"name\":\"get_weather\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
@@ -265,7 +88,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":34,\"cache_read_input_tokens\":55,\"output_tokens\":1}}\n\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
@@ -283,8 +106,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("stream-session"));
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
@@ -338,20 +160,6 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert!(request.body.contains("\"stream\":true"));
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.tracked_requests, 1);
assert_eq!(cache_stats.last_cache_creation_input_tokens, Some(34));
assert_eq!(cache_stats.last_cache_read_input_tokens, Some(55));
assert_eq!(
cache_stats.last_cache_source.as_deref(),
Some("api-response")
);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
@@ -476,121 +284,6 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_reuses_recent_completion_cache_entries() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_cached\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Cached once\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":4000,\"output_tokens\":2}}",
)],
)
.await;
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("integration-session"));
let first = client
.send_message(&sample_request(false))
.await
.expect("first request should succeed");
let second = client
.send_message(&sample_request(false))
.await
.expect("second request should reuse cache");
assert_eq!(first.content, second.content);
assert_eq!(state.lock().await.len(), 1);
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.completion_cache_hits, 1);
assert_eq!(cache_stats.completion_cache_misses, 1);
assert_eq!(cache_stats.completion_cache_writes, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_tracks_unexpected_prompt_cache_breaks() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-break-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state,
vec![
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_one\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"One\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":6000,\"output_tokens\":2}}",
),
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_two\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Two\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":1000,\"output_tokens\":2}}",
),
],
)
.await;
let request = sample_request(false);
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::with_config(PromptCacheConfig {
session_id: "break-session".to_string(),
completion_ttl: Duration::from_secs(0),
..PromptCacheConfig::default()
}));
client
.send_message(&request)
.await
.expect("first response should succeed");
client
.send_message(&request)
.await
.expect("second response should succeed");
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.unexpected_cache_breaks, 1);
assert_eq!(
cache_stats.last_break_reason.as_deref(),
Some("cache read tokens dropped while prompt fingerprint remained stable")
);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
async fn live_stream_smoke_test() {

View File

@@ -5,9 +5,8 @@ use std::sync::{Mutex as StdMutex, OnceLock};
use api::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
InputContentBlock, InputMessage, MessageRequest, OpenAiCompatClient, OpenAiCompatConfig,
OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -196,83 +195,6 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
assert!(request.body.contains("\"stream\":true"));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn openai_streaming_requests_opt_into_usage_chunks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"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\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}\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_openai_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.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_openai_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::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::MessageDelta(MessageDeltaEvent { .. })
));
assert!(matches!(events[5], StreamEvent::MessageStop(_)));
match &events[4] {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 4);
}
other => panic!("expected message delta, got {other:?}"),
}
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["stream"], json!(true));
assert_eq!(body["stream_options"], json!({"include_usage": true}));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn provider_client_dispatches_xai_requests_from_env() {
let _lock = env_lock();
@@ -467,7 +389,7 @@ 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)
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct ScopedEnvVar {

View File

@@ -57,7 +57,7 @@ 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)
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvVarGuard {

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
[package]
name = "mock-anthropic-service"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "mock-anthropic-service"
path = "src/main.rs"
[dependencies]
api = { path = "../api" }
serde_json.workspace = true
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] }
[lints]
workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
use std::env;
use mock_anthropic_service::MockAnthropicService;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut bind_addr = String::from("127.0.0.1:0");
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--bind" => {
bind_addr = args
.next()
.ok_or_else(|| "missing value for --bind".to_string())?;
}
flag if flag.starts_with("--bind=") => {
bind_addr = flag[7..].to_string();
}
"--help" | "-h" => {
println!("Usage: mock-anthropic-service [--bind HOST:PORT]");
return Ok(());
}
other => {
return Err(format!("unsupported argument: {other}").into());
}
}
}
let server = MockAnthropicService::spawn_on(&bind_addr).await?;
println!("MOCK_ANTHROPIC_BASE_URL={}", server.base_url());
tokio::signal::ctrl_c().await?;
drop(server);
Ok(())
}

View File

@@ -10,7 +10,6 @@ use crate::{PluginError, PluginHooks, PluginRegistry};
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
@@ -18,7 +17,6 @@ impl HookEvent {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
@@ -26,7 +24,6 @@ impl HookEvent {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
failed: bool,
messages: Vec<String>,
}
@@ -35,7 +32,6 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
failed: false,
messages,
}
}
@@ -45,11 +41,6 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_failed(&self) -> bool {
self.failed
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
@@ -73,7 +64,7 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
@@ -91,7 +82,7 @@ impl HookRunner {
tool_output: &str,
is_error: bool,
) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
@@ -101,24 +92,8 @@ impl HookRunner {
)
}
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
&self.hooks.post_tool_use_failure,
tool_name,
tool_input,
Some(tool_error),
true,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
@@ -130,12 +105,20 @@ impl HookRunner {
return HookRunResult::allow(Vec::new());
}
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match Self::run_command(
match self.run_command(
command,
event,
tool_name,
@@ -155,26 +138,19 @@ impl HookRunner {
}));
return HookRunResult {
denied: true,
failed: false,
messages,
};
}
HookCommandOutcome::Failed { message } => {
messages.push(message);
return HookRunResult {
denied: false,
failed: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
@@ -203,7 +179,7 @@ impl HookRunner {
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Failed {
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
@@ -211,7 +187,7 @@ impl HookRunner {
stderr.as_str(),
),
},
None => HookCommandOutcome::Failed {
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
@@ -219,7 +195,7 @@ impl HookRunner {
},
}
}
Err(error) => HookCommandOutcome::Failed {
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
@@ -232,34 +208,7 @@ impl HookRunner {
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Failed { message: String },
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> serde_json::Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
}),
}
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
@@ -267,7 +216,8 @@ fn parse_tool_input(tool_input: &str) -> serde_json::Value {
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message = format!("Hook `{command}` exited with status {code}");
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
@@ -359,13 +309,7 @@ mod tests {
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(
root: &Path,
name: &str,
pre_message: &str,
post_message: &str,
failure_message: &str,
) {
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
@@ -378,15 +322,10 @@ mod tests {
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
fs::write(
root.join("hooks").join("failure.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
)
.expect("write failure hook");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
@@ -394,7 +333,6 @@ mod tests {
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
// given
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
@@ -403,14 +341,12 @@ mod tests {
"first",
"plugin pre one",
"plugin post one",
"plugin failure one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
"plugin failure two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -422,10 +358,8 @@ mod tests {
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
// when
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
// then
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
@@ -440,13 +374,6 @@ mod tests {
"plugin post two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
HookRunResult::allow(vec![
"plugin failure one".to_string(),
"plugin failure two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
@@ -455,45 +382,14 @@ mod tests {
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
#[test]
fn propagates_plugin_hook_failures() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec![
"printf 'broken plugin hook'; exit 1".to_string(),
"printf 'later plugin hook'".to_string(),
],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken plugin hook")));
assert!(!result
.messages()
.iter()
.any(|message| message == "later plugin hook"));
}
}

View File

@@ -67,16 +67,12 @@ pub struct PluginHooks {
pub pre_tool_use: Vec<String>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<String>,
#[serde(rename = "PostToolUseFailure", default)]
pub post_tool_use_failure: Vec<String>,
}
impl PluginHooks {
#[must_use]
pub fn is_empty(&self) -> bool {
self.pre_tool_use.is_empty()
&& self.post_tool_use.is_empty()
&& self.post_tool_use_failure.is_empty()
self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
}
#[must_use]
@@ -89,9 +85,6 @@ impl PluginHooks {
.post_tool_use
.extend(other.post_tool_use.iter().cloned());
merged
.post_tool_use_failure
.extend(other.post_tool_use_failure.iter().cloned());
merged
}
}
@@ -655,106 +648,6 @@ pub struct PluginSummary {
pub enabled: bool,
}
#[derive(Debug)]
pub struct PluginLoadFailure {
pub plugin_root: PathBuf,
pub kind: PluginKind,
pub source: String,
error: Box<PluginError>,
}
impl PluginLoadFailure {
#[must_use]
pub fn new(plugin_root: PathBuf, kind: PluginKind, source: String, error: PluginError) -> Self {
Self {
plugin_root,
kind,
source,
error: Box::new(error),
}
}
#[must_use]
pub fn error(&self) -> &PluginError {
self.error.as_ref()
}
}
impl Display for PluginLoadFailure {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"failed to load {} plugin from `{}` (source: {}): {}",
self.kind,
self.plugin_root.display(),
self.source,
self.error()
)
}
}
#[derive(Debug)]
pub struct PluginRegistryReport {
registry: PluginRegistry,
failures: Vec<PluginLoadFailure>,
}
impl PluginRegistryReport {
#[must_use]
pub fn new(registry: PluginRegistry, failures: Vec<PluginLoadFailure>) -> Self {
Self { registry, failures }
}
#[must_use]
pub fn registry(&self) -> &PluginRegistry {
&self.registry
}
#[must_use]
pub fn failures(&self) -> &[PluginLoadFailure] {
&self.failures
}
#[must_use]
pub fn has_failures(&self) -> bool {
!self.failures.is_empty()
}
#[must_use]
pub fn summaries(&self) -> Vec<PluginSummary> {
self.registry.summaries()
}
pub fn into_registry(self) -> Result<PluginRegistry, PluginError> {
if self.failures.is_empty() {
Ok(self.registry)
} else {
Err(PluginError::LoadFailures(self.failures))
}
}
}
#[derive(Debug, Default)]
struct PluginDiscovery {
plugins: Vec<PluginDefinition>,
failures: Vec<PluginLoadFailure>,
}
impl PluginDiscovery {
fn push_plugin(&mut self, plugin: PluginDefinition) {
self.plugins.push(plugin);
}
fn push_failure(&mut self, failure: PluginLoadFailure) {
self.failures.push(failure);
}
fn extend(&mut self, other: Self) {
self.plugins.extend(other.plugins);
self.failures.extend(other.failures);
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginRegistry {
plugins: Vec<RegisteredPlugin>,
@@ -909,10 +802,6 @@ pub enum PluginManifestValidationError {
kind: &'static str,
path: PathBuf,
},
PathIsDirectory {
kind: &'static str,
path: PathBuf,
},
InvalidToolInputSchema {
tool_name: String,
},
@@ -949,9 +838,6 @@ impl Display for PluginManifestValidationError {
Self::MissingPath { kind, path } => {
write!(f, "{kind} path `{}` does not exist", path.display())
}
Self::PathIsDirectory { kind, path } => {
write!(f, "{kind} path `{}` must point to a file", path.display())
}
Self::InvalidToolInputSchema { tool_name } => {
write!(
f,
@@ -974,7 +860,6 @@ pub enum PluginError {
Io(std::io::Error),
Json(serde_json::Error),
ManifestValidation(Vec<PluginManifestValidationError>),
LoadFailures(Vec<PluginLoadFailure>),
InvalidManifest(String),
NotFound(String),
CommandFailed(String),
@@ -994,15 +879,6 @@ impl Display for PluginError {
}
Ok(())
}
Self::LoadFailures(failures) => {
for (index, failure) in failures.iter().enumerate() {
if index > 0 {
write!(f, "; ")?;
}
write!(f, "{failure}")?;
}
Ok(())
}
Self::InvalidManifest(message)
| Self::NotFound(message)
| Self::CommandFailed(message) => write!(f, "{message}"),
@@ -1059,23 +935,15 @@ impl PluginManager {
}
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
self.plugin_registry_report()?.into_registry()
}
pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
self.sync_bundled_plugins()?;
let mut discovery = PluginDiscovery::default();
discovery.plugins.extend(builtin_plugins());
let installed = self.discover_installed_plugins_with_failures()?;
discovery.extend(installed);
let external =
self.discover_external_directory_plugins_with_failures(&discovery.plugins)?;
discovery.extend(external);
Ok(self.build_registry_report(discovery))
Ok(PluginRegistry::new(
self.discover_plugins()?
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
))
}
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
@@ -1087,12 +955,11 @@ impl PluginManager {
}
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
Ok(self
.plugin_registry()?
.plugins
.into_iter()
.map(|plugin| plugin.definition)
.collect())
self.sync_bundled_plugins()?;
let mut plugins = builtin_plugins();
plugins.extend(self.discover_installed_plugins()?);
plugins.extend(self.discover_external_directory_plugins(&plugins)?);
Ok(plugins)
}
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
@@ -1227,9 +1094,9 @@ impl PluginManager {
})
}
fn discover_installed_plugins_with_failures(&self) -> Result<PluginDiscovery, PluginError> {
fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
let mut registry = self.load_registry()?;
let mut discovery = PluginDiscovery::default();
let mut plugins = Vec::new();
let mut seen_ids = BTreeSet::<String>::new();
let mut seen_paths = BTreeSet::<PathBuf>::new();
let mut stale_registry_ids = Vec::new();
@@ -1244,21 +1111,10 @@ impl PluginManager {
|| install_path.display().to_string(),
|record| describe_install_source(&record.source),
);
match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
Ok(plugin) => {
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(install_path);
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
install_path,
kind,
source,
error,
));
}
let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(install_path);
plugins.push(plugin);
}
}
@@ -1271,27 +1127,15 @@ impl PluginManager {
stale_registry_ids.push(record.id.clone());
continue;
}
let source = describe_install_source(&record.source);
match load_plugin_definition(
let plugin = load_plugin_definition(
&record.install_path,
record.kind,
source.clone(),
describe_install_source(&record.source),
record.kind.marketplace(),
) {
Ok(plugin) => {
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(record.install_path.clone());
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
record.install_path.clone(),
record.kind,
source,
error,
));
}
)?;
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(record.install_path.clone());
plugins.push(plugin);
}
}
@@ -1302,51 +1146,47 @@ impl PluginManager {
self.store_registry(&registry)?;
}
Ok(discovery)
Ok(plugins)
}
fn discover_external_directory_plugins_with_failures(
fn discover_external_directory_plugins(
&self,
existing_plugins: &[PluginDefinition],
) -> Result<PluginDiscovery, PluginError> {
let mut discovery = PluginDiscovery::default();
) -> Result<Vec<PluginDefinition>, PluginError> {
let mut plugins = Vec::new();
for directory in &self.config.external_dirs {
for root in discover_plugin_dirs(directory)? {
let source = root.display().to_string();
match load_plugin_definition(
let plugin = load_plugin_definition(
&root,
PluginKind::External,
source.clone(),
root.display().to_string(),
EXTERNAL_MARKETPLACE,
) {
Ok(plugin) => {
if existing_plugins
.iter()
.chain(discovery.plugins.iter())
.all(|existing| existing.metadata().id != plugin.metadata().id)
{
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
root,
PluginKind::External,
source,
error,
));
}
)?;
if existing_plugins
.iter()
.chain(plugins.iter())
.all(|existing| existing.metadata().id != plugin.metadata().id)
{
plugins.push(plugin);
}
}
}
Ok(discovery)
Ok(plugins)
}
pub fn installed_plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
self.sync_bundled_plugins()?;
Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
Ok(PluginRegistry::new(
self.discover_installed_plugins()?
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
))
}
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
@@ -1492,26 +1332,6 @@ impl PluginManager {
}
})
}
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
self.installed_plugin_registry_report()?.into_registry()
}
fn build_registry_report(&self, discovery: PluginDiscovery) -> PluginRegistryReport {
PluginRegistryReport::new(
PluginRegistry::new(
discovery
.plugins
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
),
discovery.failures,
)
}
}
#[must_use]
@@ -1629,12 +1449,6 @@ fn build_plugin_manifest(
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
validate_command_entries(
root,
raw.hooks.post_tool_use_failure.iter(),
"hook",
&mut errors,
);
validate_command_entries(
root,
raw.lifecycle.init.iter(),
@@ -1862,8 +1676,6 @@ fn validate_command_entry(
};
if !path.exists() {
errors.push(PluginManifestValidationError::MissingPath { kind, path });
} else if !path.is_file() {
errors.push(PluginManifestValidationError::PathIsDirectory { kind, path });
}
}
@@ -1879,11 +1691,6 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
post_tool_use_failure: hooks
.post_tool_use_failure
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
}
}
@@ -1932,12 +1739,7 @@ fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), P
let Some(root) = root else {
return Ok(());
};
for entry in hooks
.pre_tool_use
.iter()
.chain(hooks.post_tool_use.iter())
.chain(hooks.post_tool_use_failure.iter())
{
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
validate_command_path(root, entry, "hook")?;
}
Ok(())
@@ -1981,12 +1783,6 @@ fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), Plu
path.display()
)));
}
if !path.is_file() {
return Err(PluginError::InvalidManifest(format!(
"{kind} path `{}` must point to a file",
path.display()
)));
}
Ok(())
}
@@ -2298,30 +2094,6 @@ mod tests {
);
}
fn write_directory_path_plugin(root: &Path, name: &str) {
fs::create_dir_all(root.join("hooks").join("pre-dir")).expect("hook dir");
fs::create_dir_all(root.join("tools").join("tool-dir")).expect("tool dir");
fs::create_dir_all(root.join("commands").join("sync-dir")).expect("command dir");
fs::create_dir_all(root.join("lifecycle").join("init-dir")).expect("lifecycle dir");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"directory path plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre-dir\"]\n }},\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init-dir\"]\n }},\n \"tools\": [\n {{\n \"name\": \"dir_tool\",\n \"description\": \"Directory tool\",\n \"inputSchema\": {{\"type\": \"object\"}},\n \"command\": \"./tools/tool-dir\"\n }}\n ],\n \"commands\": [\n {{\n \"name\": \"sync\",\n \"description\": \"Directory command\",\n \"command\": \"./commands/sync-dir\"\n }}\n ]\n}}"
)
.as_str(),
);
}
fn write_broken_failure_hook_plugin(root: &Path, name: &str) {
write_file(
root.join(MANIFEST_RELATIVE_PATH).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PostToolUseFailure\": [\"./hooks/missing-failure.sh\"]\n }}\n}}"
)
.as_str(),
);
}
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
let log_path = root.join("lifecycle.log");
write_file(
@@ -2543,90 +2315,6 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_missing_lifecycle_paths() {
// given
let root = temp_dir("manifest-lifecycle-paths");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "missing-lifecycle-paths",
"version": "1.0.0",
"description": "Missing lifecycle path validation",
"lifecycle": {
"Init": ["./lifecycle/init.sh"],
"Shutdown": ["./lifecycle/shutdown.sh"]
}
}"#,
);
// when
let error =
load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
// then
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::MissingPath { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/init.sh"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::MissingPath { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/shutdown.sh"))
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_directory_command_paths() {
// given
let root = temp_dir("manifest-directory-paths");
write_directory_path_plugin(&root, "directory-paths");
// when
let error =
load_plugin_from_directory(&root).expect_err("directory command paths should fail");
// then
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "hook" && path.ends_with(Path::new("hooks/pre-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/init-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "tool" && path.ends_with(Path::new("tools/tool-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "command" && path.ends_with(Path::new("commands/sync-dir"))
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_invalid_permissions() {
let root = temp_dir("manifest-invalid-permissions");
@@ -3118,95 +2806,16 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
// given
let config_home = temp_dir("report-home");
let external_root = temp_dir("report-external");
write_external_plugin(&external_root.join("valid"), "valid-report", "1.0.0");
write_broken_plugin(&external_root.join("broken"), "broken-report");
let mut config = PluginManagerConfig::new(&config_home);
config.external_dirs = vec![external_root.clone()];
let manager = PluginManager::new(config);
// when
let report = manager
.plugin_registry_report()
.expect("report should tolerate invalid external plugins");
// then
assert!(report.registry().contains("valid-report@external"));
assert_eq!(report.failures().len(), 1);
assert_eq!(report.failures()[0].kind, PluginKind::External);
assert!(report.failures()[0]
.plugin_root
.ends_with(Path::new("broken")));
assert!(report.failures()[0]
.error()
.to_string()
.contains("does not exist"));
let error = manager
.plugin_registry()
.expect_err("strict registry should surface load failures");
match error {
PluginError::LoadFailures(failures) => {
assert_eq!(failures.len(), 1);
assert!(failures[0].plugin_root.ends_with(Path::new("broken")));
}
other => panic!("expected load failures, got {other}"),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(external_root);
}
#[test]
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
// given
let config_home = temp_dir("installed-report-home");
let bundled_root = temp_dir("installed-report-bundled");
let install_root = config_home.join("plugins").join("installed");
write_external_plugin(&install_root.join("valid"), "installed-valid", "1.0.0");
write_broken_plugin(&install_root.join("broken"), "installed-broken");
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
config.install_root = Some(install_root);
let manager = PluginManager::new(config);
// when
let report = manager
.installed_plugin_registry_report()
.expect("installed report should tolerate invalid installed plugins");
// then
assert!(report.registry().contains("installed-valid@external"));
assert_eq!(report.failures().len(), 1);
assert!(report.failures()[0]
.plugin_root
.ends_with(Path::new("broken")));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
// given
let config_home = temp_dir("broken-home");
let source_root = temp_dir("broken-source");
write_broken_plugin(&source_root, "broken");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -3219,33 +2828,6 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
// given
let config_home = temp_dir("broken-failure-home");
let source_root = temp_dir("broken-failure-source");
write_broken_failure_hook_plugin(&source_root, "broken-failure");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing failure hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install_error = manager
.install(source_root.to_str().expect("utf8 path"))
.expect_err("install should reject invalid failure hook paths");
assert!(install_error.to_string().contains("does not exist"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let config_home = temp_dir("lifecycle-home");

View File

@@ -12,7 +12,6 @@ plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2"

View File

@@ -14,7 +14,6 @@ use crate::sandbox::{
};
use crate::ConfigLoader;
/// Input schema for the built-in bash execution tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
pub command: String,
@@ -34,7 +33,6 @@ pub struct BashCommandInput {
pub allowed_mounts: Option<Vec<String>>,
}
/// Output returned from a bash tool invocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashCommandOutput {
pub stdout: String,
@@ -66,7 +64,6 @@ pub struct BashCommandOutput {
pub sandbox_status: Option<SandboxStatus>,
}
/// Executes a shell command with the requested sandbox settings.
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let cwd = env::current_dir()?;
let sandbox_status = sandbox_status_for_input(&input, &cwd);
@@ -137,8 +134,8 @@ async fn execute_bash_async(
};
let (output, interrupted) = output_result;
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
let return_code_interpretation = output.status.code().and_then(|code| {
if code == 0 {
@@ -284,53 +281,3 @@ mod tests {
assert!(!output.sandbox_status.expect("sandbox status").enabled);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).
const MAX_OUTPUT_BYTES: usize = 16_384;
/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed.
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_BYTES {
return s.to_string();
}
// Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES
let mut end = MAX_OUTPUT_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut truncated = s[..end].to_string();
truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
truncated
}
#[cfg(test)]
mod truncation_tests {
use super::*;
#[test]
fn short_output_unchanged() {
let s = "hello world";
assert_eq!(truncate_output(s), s);
}
#[test]
fn long_output_truncated() {
let s = "x".repeat(20_000);
let result = truncate_output(&s);
assert!(result.len() < 20_000);
assert!(result.ends_with("[output truncated — exceeded 16384 bytes]"));
}
#[test]
fn exact_boundary_unchanged() {
let s = "a".repeat(MAX_OUTPUT_BYTES);
assert_eq!(truncate_output(&s), s);
}
#[test]
fn one_over_boundary_truncated() {
let s = "a".repeat(MAX_OUTPUT_BYTES + 1);
let result = truncate_output(&s);
assert!(result.contains("[output truncated"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,58 +54,3 @@ impl BootstrapPlan {
&self.phases
}
}
#[cfg(test)]
mod tests {
use super::{BootstrapPhase, BootstrapPlan};
#[test]
fn from_phases_deduplicates_while_preserving_order() {
// given
let phases = vec![
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::CliEntry,
BootstrapPhase::MainRuntime,
BootstrapPhase::FastPathVersion,
];
// when
let plan = BootstrapPlan::from_phases(phases);
// then
assert_eq!(
plan.phases(),
&[
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::MainRuntime,
]
);
}
#[test]
fn claude_code_default_covers_each_phase_once() {
// given
let expected = [
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::StartupProfiler,
BootstrapPhase::SystemPromptFastPath,
BootstrapPhase::ChromeMcpFastPath,
BootstrapPhase::DaemonWorkerFastPath,
BootstrapPhase::BridgeFastPath,
BootstrapPhase::DaemonFastPath,
BootstrapPhase::BackgroundSessionFastPath,
BootstrapPhase::TemplateFastPath,
BootstrapPhase::EnvironmentRunnerFastPath,
BootstrapPhase::MainRuntime,
];
// when
let plan = BootstrapPlan::claude_code_default();
// then
assert_eq!(plan.phases(), &expected);
}
}

View File

@@ -5,7 +5,6 @@ const COMPACT_CONTINUATION_PREAMBLE: &str =
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
/// Thresholds controlling when and how a session is compacted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -21,7 +20,6 @@ impl Default for CompactionConfig {
}
}
/// Result of compacting a session into a summary plus preserved tail messages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionResult {
pub summary: String,
@@ -30,13 +28,11 @@ pub struct CompactionResult {
pub removed_message_count: usize,
}
/// Roughly estimates the token footprint of the current session transcript.
#[must_use]
pub fn estimate_session_tokens(session: &Session) -> usize {
session.messages.iter().map(estimate_message_tokens).sum()
}
/// Returns `true` when the session exceeds the configured compaction budget.
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
let start = compacted_summary_prefix_len(session);
@@ -50,7 +46,6 @@ pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
>= config.max_estimated_tokens
}
/// Normalizes a compaction summary into user-facing continuation text.
#[must_use]
pub fn format_compact_summary(summary: &str) -> String {
let without_analysis = strip_tag_block(summary, "analysis");
@@ -66,7 +61,6 @@ pub fn format_compact_summary(summary: &str) -> String {
collapse_blank_lines(&formatted).trim().to_string()
}
/// Builds the synthetic system message used after session compaction.
#[must_use]
pub fn get_compact_continuation_message(
summary: &str,
@@ -91,7 +85,6 @@ pub fn get_compact_continuation_message(
base
}
/// Compacts a session by summarizing older messages and preserving the recent tail.
#[must_use]
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
if !should_compact(session, config) {
@@ -126,14 +119,13 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}];
compacted_messages.extend(preserved);
let mut compacted_session = session.clone();
compacted_session.messages = compacted_messages;
compacted_session.record_compaction(summary.clone(), removed.len());
CompactionResult {
summary,
formatted_summary,
compacted_session,
compacted_session: Session {
version: session.version,
messages: compacted_messages,
},
removed_message_count: removed.len(),
}
}
@@ -523,8 +515,10 @@ mod tests {
#[test]
fn leaves_small_sessions_unchanged() {
let mut session = Session::new();
session.messages = vec![ConversationMessage::user_text("hello")];
let session = Session {
version: 1,
messages: vec![ConversationMessage::user_text("hello")],
};
let result = compact_session(&session, CompactionConfig::default());
assert_eq!(result.removed_message_count, 0);
@@ -535,21 +529,23 @@ mod tests {
#[test]
fn compacts_older_messages_into_a_system_summary() {
let mut session = Session::new();
session.messages = vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
];
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
],
};
let result = compact_session(
&session,
@@ -584,17 +580,21 @@ mod tests {
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let mut initial_session = Session::new();
initial_session.messages = vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text("Also update rust/crates/runtime/src/conversation.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
];
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
@@ -609,9 +609,13 @@ mod tests {
}]),
]);
let mut second_session = Session::new();
second_session.messages = follow_up_messages;
let second = compact_session(&second_session, config);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
assert!(second
.formatted_summary
@@ -640,20 +644,22 @@ mod tests {
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let mut session = Session::new();
session.messages = vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
];
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
assert!(!should_compact(
&session,

View File

@@ -6,10 +6,8 @@ use std::path::{Path, PathBuf};
use crate::json::JsonValue;
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
/// Schema name advertised by generated settings files.
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
/// Origin of a loaded settings file in the configuration precedence chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigSource {
User,
@@ -17,7 +15,6 @@ pub enum ConfigSource {
Local,
}
/// Effective permission mode after decoding config values.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPermissionMode {
ReadOnly,
@@ -25,14 +22,12 @@ pub enum ResolvedPermissionMode {
DangerFullAccess,
}
/// A discovered config file and the scope it contributes to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry {
pub source: ConfigSource,
pub path: PathBuf,
}
/// Fully merged runtime configuration plus parsed feature-specific views.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfig {
merged: BTreeMap<String, JsonValue>,
@@ -40,7 +35,6 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
/// Parsed plugin-related settings extracted from runtime config.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePluginConfig {
enabled_plugins: BTreeMap<String, bool>,
@@ -50,7 +44,6 @@ pub struct RuntimePluginConfig {
bundled_root: Option<String>,
}
/// Structured feature configuration consumed by runtime subsystems.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
@@ -59,40 +52,26 @@ pub struct RuntimeFeatureConfig {
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
}
/// Hook command lists grouped by lifecycle stage.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
}
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
allow: Vec<String>,
deny: Vec<String>,
ask: Vec<String>,
}
/// Collection of configured MCP servers after scope-aware merging.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct McpConfigCollection {
servers: BTreeMap<String, ScopedMcpServerConfig>,
}
/// MCP server config paired with the scope that defined it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedMcpServerConfig {
pub scope: ConfigSource,
pub config: McpServerConfig,
}
/// Transport families supported by configured MCP servers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTransport {
Stdio,
@@ -103,7 +82,6 @@ pub enum McpTransport {
ManagedProxy,
}
/// Scope-normalized MCP server configuration variants.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerConfig {
Stdio(McpStdioServerConfig),
@@ -114,16 +92,13 @@ pub enum McpServerConfig {
ManagedProxy(McpManagedProxyServerConfig),
}
/// Configuration for an MCP server launched as a local stdio process.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpStdioServerConfig {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub tool_call_timeout_ms: Option<u64>,
}
/// Configuration for an MCP server reached over HTTP or SSE.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpRemoteServerConfig {
pub url: String,
@@ -132,7 +107,6 @@ pub struct McpRemoteServerConfig {
pub oauth: Option<McpOAuthConfig>,
}
/// Configuration for an MCP server reached over WebSocket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpWebSocketServerConfig {
pub url: String,
@@ -140,20 +114,17 @@ pub struct McpWebSocketServerConfig {
pub headers_helper: Option<String>,
}
/// Configuration for an MCP server addressed through an SDK name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpSdkServerConfig {
pub name: String,
}
/// Configuration for an MCP managed-proxy endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpManagedProxyServerConfig {
pub url: String,
pub id: String,
}
/// OAuth overrides associated with a remote MCP server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpOAuthConfig {
pub client_id: Option<String>,
@@ -162,7 +133,6 @@ pub struct McpOAuthConfig {
pub xaa: Option<bool>,
}
/// OAuth client configuration used by the main Claw runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthConfig {
pub client_id: String,
@@ -173,7 +143,6 @@ pub struct OAuthConfig {
pub scopes: Vec<String>,
}
/// Errors raised while reading or parsing runtime configuration files.
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
@@ -197,7 +166,6 @@ impl From<std::io::Error> for ConfigError {
}
}
/// Discovers config files and merges them into a [`RuntimeConfig`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigLoader {
cwd: PathBuf,
@@ -264,7 +232,6 @@ impl ConfigLoader {
let Some(value) = read_optional_json_object(&entry.path)? else {
continue;
};
validate_optional_hooks_config(&value, &entry.path)?;
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
deep_merge_objects(&mut merged, &value);
loaded_entries.push(entry);
@@ -281,7 +248,6 @@ impl ConfigLoader {
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
};
@@ -358,11 +324,6 @@ impl RuntimeConfig {
self.feature_config.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.feature_config.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.feature_config.sandbox
@@ -412,11 +373,6 @@ impl RuntimeFeatureConfig {
self.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
@@ -463,7 +419,6 @@ impl RuntimePluginConfig {
}
#[must_use]
/// Returns the default per-user config directory used by the runtime.
pub fn default_config_home() -> PathBuf {
std::env::var_os("CLAW_CONFIG_HOME")
.map(PathBuf::from)
@@ -473,15 +428,10 @@ pub fn default_config_home() -> PathBuf {
impl RuntimeHookConfig {
#[must_use]
pub fn new(
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
) -> Self {
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
Self {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
}
}
@@ -505,37 +455,6 @@ impl RuntimeHookConfig {
pub fn extend(&mut self, other: &Self) {
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
extend_unique(&mut self.post_tool_use, other.post_tool_use());
extend_unique(
&mut self.post_tool_use_failure,
other.post_tool_use_failure(),
);
}
#[must_use]
pub fn post_tool_use_failure(&self) -> &[String] {
&self.post_tool_use_failure
}
}
impl RuntimePermissionRuleConfig {
#[must_use]
pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
Self { allow, deny, ask }
}
#[must_use]
pub fn allow(&self) -> &[String] {
&self.allow
}
#[must_use]
pub fn deny(&self) -> &[String] {
&self.deny
}
#[must_use]
pub fn ask(&self) -> &[String] {
&self.ask
}
}
@@ -588,7 +507,7 @@ fn read_optional_json_object(
let parsed = match JsonValue::parse(&contents) {
Ok(parsed) => parsed,
Err(_error) if is_legacy_config => return Ok(None),
Err(error) if is_legacy_config => return Ok(None),
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
};
let Some(object) = parsed.as_object() else {
@@ -641,48 +560,14 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
let Some(object) = root.as_object() else {
return Ok(RuntimeHookConfig::default());
};
parse_optional_hooks_config_object(object, "merged settings.hooks")
}
fn parse_optional_hooks_config_object(
object: &BTreeMap<String, JsonValue>,
context: &str,
) -> Result<RuntimeHookConfig, ConfigError> {
let Some(hooks_value) = object.get("hooks") else {
return Ok(RuntimeHookConfig::default());
};
let hooks = expect_object(hooks_value, context)?;
let hooks = expect_object(hooks_value, "merged settings.hooks")?;
Ok(RuntimeHookConfig {
pre_tool_use: optional_string_array(hooks, "PreToolUse", context)?.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(),
post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)?
pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
.unwrap_or_default(),
})
}
fn validate_optional_hooks_config(
root: &BTreeMap<String, JsonValue>,
path: &Path,
) -> Result<(), ConfigError> {
parse_optional_hooks_config_object(root, &format!("{}: hooks", path.display())).map(|_| ())
}
fn parse_optional_permission_rules(
root: &JsonValue,
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePermissionRuleConfig::default());
};
let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
return Ok(RuntimePermissionRuleConfig::default());
};
Ok(RuntimePermissionRuleConfig {
allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
.unwrap_or_default(),
deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
.unwrap_or_default(),
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
})
}
@@ -818,14 +703,12 @@ fn parse_mcp_server_config(
context: &str,
) -> Result<McpServerConfig, ConfigError> {
let object = expect_object(value, context)?;
let server_type =
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
match server_type {
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
command: expect_string(object, "command", context)?.to_string(),
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
})),
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
object, context,
@@ -851,14 +734,6 @@ fn parse_mcp_server_config(
}
}
fn infer_mcp_server_type(object: &BTreeMap<String, JsonValue>) -> &'static str {
if object.contains_key("url") {
"http"
} else {
"stdio"
}
}
fn parse_mcp_remote_server_config(
object: &BTreeMap<String, JsonValue>,
context: &str,
@@ -957,27 +832,6 @@ fn optional_u16(
}
}
fn optional_u64(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<u64>, ConfigError> {
match object.get(key) {
Some(value) => {
let Some(number) = value.as_i64() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key} must be a non-negative integer"
)));
};
let number = u64::try_from(number).map_err(|_| {
ConfigError::Parse(format!("{context}: field {key} is out of range"))
})?;
Ok(Some(number))
}
None => Ok(None),
}
}
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
let Some(map) = value.as_object() else {
return Err(ConfigError::Parse(format!(
@@ -1085,9 +939,8 @@ fn push_unique(target: &mut Vec<String>, value: String) {
#[cfg(test)]
mod tests {
use super::{
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
CLAW_SETTINGS_SCHEMA_NAME,
};
use crate::json::JsonValue;
use crate::sandbox::FilesystemIsolationMode;
@@ -1118,9 +971,7 @@ mod tests {
.to_string()
.contains("top-level settings value must be a JSON object"));
if root.exists() {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
@@ -1138,7 +989,7 @@ mod tests {
.expect("write user compat config");
fs::write(
home.join("settings.json"),
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
)
.expect("write user settings");
fs::write(
@@ -1148,7 +999,7 @@ mod tests {
.expect("write project compat config");
fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
@@ -1193,16 +1044,6 @@ mod tests {
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert_eq!(
loaded.hooks().post_tool_use_failure(),
&["project-failure".to_string()]
);
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
assert_eq!(
loaded.permission_rules().deny(),
&["Bash(rm -rf)".to_string()]
);
assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
@@ -1338,44 +1179,6 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn infers_http_mcp_servers_from_url_only_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"mcpServers": {
"remote": {
"url": "https://example.test/mcp"
}
}
}"#,
)
.expect("write mcp settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
let remote_server = loaded
.mcp()
.get("remote")
.expect("remote server should exist");
assert_eq!(remote_server.transport(), McpTransport::Http);
match &remote_server.config {
McpServerConfig::Http(config) => {
assert_eq!(config.url, "https://example.test/mcp");
}
other => panic!("expected http config, got {other:?}"),
}
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config_from_enabled_plugins() {
let root = temp_dir();
@@ -1468,7 +1271,6 @@ mod tests {
#[test]
fn rejects_invalid_mcp_server_shapes() {
// given
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
@@ -1480,169 +1282,13 @@ mod tests {
)
.expect("write broken settings");
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect_err("config should fail");
// then
assert!(error
.to_string()
.contains("mcpServers.broken: missing string field url"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn empty_settings_file_loads_defaults() {
// given
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(home.join("settings.json"), "").expect("write empty settings");
// when
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("empty settings should still load");
// then
assert_eq!(loaded.loaded_entries().len(), 1);
assert_eq!(loaded.permission_mode(), None);
assert_eq!(loaded.plugins().enabled_plugins().len(), 0);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn deep_merge_objects_merges_nested_maps() {
// given
let mut target = JsonValue::parse(r#"{"env":{"A":"1","B":"2"},"model":"haiku"}"#)
.expect("target JSON should parse")
.as_object()
.expect("target should be an object")
.clone();
let source =
JsonValue::parse(r#"{"env":{"B":"override","C":"3"},"sandbox":{"enabled":true}}"#)
.expect("source JSON should parse")
.as_object()
.expect("source should be an object")
.clone();
// when
deep_merge_objects(&mut target, &source);
// then
let env = target
.get("env")
.and_then(JsonValue::as_object)
.expect("env should remain an object");
assert_eq!(env.get("A"), Some(&JsonValue::String("1".to_string())));
assert_eq!(
env.get("B"),
Some(&JsonValue::String("override".to_string()))
);
assert_eq!(env.get("C"), Some(&JsonValue::String("3".to_string())));
assert!(target.contains_key("sandbox"));
}
#[test]
fn rejects_invalid_hook_entries_before_merge() {
// given
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
let project_settings = cwd.join(".claw").join("settings.json");
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":["base"]}}"#,
)
.expect("write user settings");
fs::write(
&project_settings,
r#"{"hooks":{"PreToolUse":["project",42]}}"#,
)
.expect("write invalid project settings");
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect_err("config should fail");
// then
let rendered = error.to_string();
assert!(rendered.contains(&format!(
"{}: hooks: field PreToolUse must contain only strings",
project_settings.display()
)));
assert!(!rendered.contains("merged settings.hooks"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn permission_mode_aliases_resolve_to_expected_modes() {
// given / when / then
assert_eq!(
parse_permission_mode_label("plan", "test").expect("plan should resolve"),
ResolvedPermissionMode::ReadOnly
);
assert_eq!(
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
ResolvedPermissionMode::WorkspaceWrite
);
assert_eq!(
parse_permission_mode_label("dontAsk", "test").expect("dontAsk should resolve"),
ResolvedPermissionMode::DangerFullAccess
);
}
#[test]
fn hook_config_merge_preserves_uniques() {
// given
let base = RuntimeHookConfig::new(
vec!["pre-a".to_string()],
vec!["post-a".to_string()],
vec!["failure-a".to_string()],
);
let overlay = RuntimeHookConfig::new(
vec!["pre-a".to_string(), "pre-b".to_string()],
vec!["post-a".to_string(), "post-b".to_string()],
vec!["failure-b".to_string()],
);
// when
let merged = base.merged(&overlay);
// then
assert_eq!(
merged.pre_tool_use(),
&["pre-a".to_string(), "pre-b".to_string()]
);
assert_eq!(
merged.post_tool_use(),
&["post-a".to_string(), "post-b".to_string()]
);
assert_eq!(
merged.post_tool_use_failure(),
&["failure-a".to_string(), "failure-b".to_string()]
);
}
#[test]
fn plugin_state_falls_back_to_default_for_unknown_plugin() {
// given
let mut config = RuntimePluginConfig::default();
config.set_plugin_state("known".to_string(), true);
// when / then
assert!(config.state_for("known", false));
assert!(config.state_for("missing", true));
assert!(!config.state_for("missing", false));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,41 +9,6 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum file size that can be written (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
/// Check whether a file appears to contain binary content by examining
/// the first chunk for NUL bytes.
fn is_binary_file(path: &Path) -> io::Result<bool> {
use std::io::Read;
let mut file = fs::File::open(path)?;
let mut buffer = [0u8; 8192];
let bytes_read = file.read(&mut buffer)?;
Ok(buffer[..bytes_read].contains(&0))
}
/// Validate that a resolved path stays within the given workspace root.
/// Returns the canonical path on success, or an error if the path escapes
/// the workspace boundary (e.g. via `../` traversal or symlink).
#[allow(dead_code)]
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
if !resolved.starts_with(workspace_root) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"path {} escapes workspace boundary {}",
resolved.display(),
workspace_root.display()
),
));
}
Ok(())
}
/// Text payload returned by file-reading operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TextFilePayload {
#[serde(rename = "filePath")]
@@ -57,7 +22,6 @@ pub struct TextFilePayload {
pub total_lines: usize,
}
/// Output envelope for the `read_file` tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReadFileOutput {
#[serde(rename = "type")]
@@ -65,7 +29,6 @@ pub struct ReadFileOutput {
pub file: TextFilePayload,
}
/// Structured patch hunk emitted by write and edit operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StructuredPatchHunk {
#[serde(rename = "oldStart")]
@@ -79,7 +42,6 @@ pub struct StructuredPatchHunk {
pub lines: Vec<String>,
}
/// Output envelope for full-file write operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WriteFileOutput {
#[serde(rename = "type")]
@@ -95,7 +57,6 @@ pub struct WriteFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Output envelope for targeted string-replacement edits.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EditFileOutput {
#[serde(rename = "filePath")]
@@ -116,7 +77,6 @@ pub struct EditFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Result of a glob-based filename search.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobSearchOutput {
#[serde(rename = "durationMs")]
@@ -127,7 +87,6 @@ pub struct GlobSearchOutput {
pub truncated: bool,
}
/// Parameters accepted by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchInput {
pub pattern: String,
@@ -153,7 +112,6 @@ pub struct GrepSearchInput {
pub multiline: Option<bool>,
}
/// Result payload returned by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchOutput {
pub mode: Option<String>,
@@ -171,35 +129,12 @@ pub struct GrepSearchOutput {
pub applied_offset: Option<usize>,
}
/// Reads a text file and returns a line-windowed payload.
pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
// Check file size before reading
let metadata = fs::metadata(&absolute_path)?;
if metadata.len() > MAX_READ_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"file is too large ({} bytes, max {} bytes)",
metadata.len(),
MAX_READ_SIZE
),
));
}
// Detect binary files
if is_binary_file(&absolute_path)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len());
@@ -220,19 +155,7 @@ pub fn read_file(
})
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"content is too large ({} bytes, max {} bytes)",
content.len(),
MAX_WRITE_SIZE
),
));
}
let absolute_path = normalize_path_allow_missing(path)?;
let original_file = fs::read_to_string(&absolute_path).ok();
if let Some(parent) = absolute_path.parent() {
@@ -254,7 +177,6 @@ pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
})
}
/// Performs an in-file string replacement and returns patch metadata.
pub fn edit_file(
path: &str,
old_string: &str,
@@ -295,7 +217,6 @@ pub fn edit_file(
})
}
/// Expands a glob pattern and returns matching filenames.
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
let started = Instant::now();
let base_dir = path
@@ -339,7 +260,6 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
})
}
/// Runs a regex search over workspace files with optional context lines.
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
let base_path = input
.path
@@ -557,76 +477,11 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
Ok(candidate)
}
/// Read a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file(path, offset, limit)
}
/// Write a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn write_file_in_workspace(
path: &str,
content: &str,
workspace_root: &Path,
) -> io::Result<WriteFileOutput> {
let absolute_path = normalize_path_allow_missing(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
write_file(path, content)
}
/// Edit a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn edit_file_in_workspace(
path: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
workspace_root: &Path,
) -> io::Result<EditFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
edit_file(path, old_string, new_string, replace_all)
}
/// Check whether a path is a symlink that resolves outside the workspace.
#[allow(dead_code)]
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_symlink() {
return Ok(false);
}
let resolved = path.canonicalize()?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
Ok(!resolved.starts_with(&canonical_root))
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
edit_file, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace,
write_file, GrepSearchInput, MAX_WRITE_SIZE,
};
use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
fn temp_path(name: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
@@ -658,73 +513,6 @@ mod tests {
assert!(output.replace_all);
}
#[test]
fn rejects_binary_files() {
let path = temp_path("binary-test.bin");
std::fs::write(&path, b"\x00\x01\x02\x03binary content").expect("write should succeed");
let result = read_file(path.to_string_lossy().as_ref(), None, None);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("binary"));
}
#[test]
fn rejects_oversized_writes() {
let path = temp_path("oversize-write.txt");
let huge = "x".repeat(MAX_WRITE_SIZE + 1);
let result = write_file(path.to_string_lossy().as_ref(), &huge);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("too large"));
}
#[test]
fn enforces_workspace_boundary() {
let workspace = temp_path("workspace-boundary");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let inside = workspace.join("inside.txt");
write_file(inside.to_string_lossy().as_ref(), "safe content")
.expect("write inside workspace should succeed");
// Reading inside workspace should succeed
let result =
read_file_in_workspace(inside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_ok());
// Reading outside workspace should fail
let outside = temp_path("outside-boundary.txt");
write_file(outside.to_string_lossy().as_ref(), "unsafe content")
.expect("write outside should succeed");
let result =
read_file_in_workspace(outside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("escapes workspace"));
}
#[test]
fn detects_symlink_escape() {
let workspace = temp_path("symlink-workspace");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let outside = temp_path("symlink-target.txt");
std::fs::write(&outside, "target content").expect("target should write");
let link_path = workspace.join("escape-link.txt");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create");
assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed"));
}
// Non-symlink file should not be an escape
let normal = workspace.join("normal.txt");
std::fs::write(&normal, "normal content").expect("normal file should write");
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
}
#[test]
fn globs_and_greps_directory() {
let dir = temp_path("search-dir");

View File

@@ -1,152 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GreenLevel {
TargetedTests,
Package,
Workspace,
MergeReady,
}
impl GreenLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::TargetedTests => "targeted_tests",
Self::Package => "package",
Self::Workspace => "workspace",
Self::MergeReady => "merge_ready",
}
}
}
impl std::fmt::Display for GreenLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GreenContract {
pub required_level: GreenLevel,
}
impl GreenContract {
#[must_use]
pub fn new(required_level: GreenLevel) -> Self {
Self { required_level }
}
#[must_use]
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
match observed_level {
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
required_level: self.required_level,
observed_level: level,
},
_ => GreenContractOutcome::Unsatisfied {
required_level: self.required_level,
observed_level,
},
}
}
#[must_use]
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
observed_level >= self.required_level
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum GreenContractOutcome {
Satisfied {
required_level: GreenLevel,
observed_level: GreenLevel,
},
Unsatisfied {
required_level: GreenLevel,
observed_level: Option<GreenLevel>,
},
}
impl GreenContractOutcome {
#[must_use]
pub fn is_satisfied(&self) -> bool {
matches!(self, Self::Satisfied { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_matching_level_when_evaluating_contract_then_it_is_satisfied() {
// given
let contract = GreenContract::new(GreenLevel::Package);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Satisfied {
required_level: GreenLevel::Package,
observed_level: GreenLevel::Package,
}
);
assert!(outcome.is_satisfied());
}
#[test]
fn given_higher_level_when_checking_requirement_then_it_still_satisfies_contract() {
// given
let contract = GreenContract::new(GreenLevel::TargetedTests);
// when
let is_satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
// then
assert!(is_satisfied);
}
#[test]
fn given_lower_level_when_evaluating_contract_then_it_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::Workspace);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::Workspace,
observed_level: Some(GreenLevel::Package),
}
);
assert!(!outcome.is_satisfied());
}
#[test]
fn given_no_green_level_when_evaluating_contract_then_contract_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::MergeReady);
// when
let outcome = contract.evaluate(None);
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::MergeReady,
observed_level: None,
}
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,241 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaneEventName {
#[serde(rename = "lane.started")]
Started,
#[serde(rename = "lane.ready")]
Ready,
#[serde(rename = "lane.prompt_misdelivery")]
PromptMisdelivery,
#[serde(rename = "lane.blocked")]
Blocked,
#[serde(rename = "lane.red")]
Red,
#[serde(rename = "lane.green")]
Green,
#[serde(rename = "lane.commit.created")]
CommitCreated,
#[serde(rename = "lane.pr.opened")]
PrOpened,
#[serde(rename = "lane.merge.ready")]
MergeReady,
#[serde(rename = "lane.finished")]
Finished,
#[serde(rename = "lane.failed")]
Failed,
#[serde(rename = "lane.reconciled")]
Reconciled,
#[serde(rename = "lane.merged")]
Merged,
#[serde(rename = "lane.superseded")]
Superseded,
#[serde(rename = "lane.closed")]
Closed,
#[serde(rename = "branch.stale_against_main")]
BranchStaleAgainstMain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LaneEventStatus {
Running,
Ready,
Blocked,
Red,
Green,
Completed,
Failed,
Reconciled,
Merged,
Superseded,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LaneFailureClass {
PromptDelivery,
TrustGate,
BranchDivergence,
Compile,
Test,
PluginStartup,
McpStartup,
McpHandshake,
GatewayRouting,
ToolRuntime,
Infra,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEventBlocker {
#[serde(rename = "failureClass")]
pub failure_class: LaneFailureClass,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEvent {
pub event: LaneEventName,
pub status: LaneEventStatus,
#[serde(rename = "emittedAt")]
pub emitted_at: String,
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
pub failure_class: Option<LaneFailureClass>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl LaneEvent {
#[must_use]
pub fn new(
event: LaneEventName,
status: LaneEventStatus,
emitted_at: impl Into<String>,
) -> Self {
Self {
event,
status,
emitted_at: emitted_at.into(),
failure_class: None,
detail: None,
data: None,
}
}
#[must_use]
pub fn started(emitted_at: impl Into<String>) -> Self {
Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at)
}
#[must_use]
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
.with_optional_detail(detail)
}
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
}
#[must_use]
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
}
#[must_use]
pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self {
self.failure_class = Some(failure_class);
self
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
#[must_use]
pub fn with_optional_detail(mut self, detail: Option<String>) -> Self {
self.detail = detail;
self
}
#[must_use]
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
};
#[test]
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
let cases = [
(LaneEventName::Started, "lane.started"),
(LaneEventName::Ready, "lane.ready"),
(
LaneEventName::PromptMisdelivery,
"lane.prompt_misdelivery",
),
(LaneEventName::Blocked, "lane.blocked"),
(LaneEventName::Red, "lane.red"),
(LaneEventName::Green, "lane.green"),
(LaneEventName::CommitCreated, "lane.commit.created"),
(LaneEventName::PrOpened, "lane.pr.opened"),
(LaneEventName::MergeReady, "lane.merge.ready"),
(LaneEventName::Finished, "lane.finished"),
(LaneEventName::Failed, "lane.failed"),
(LaneEventName::Reconciled, "lane.reconciled"),
(LaneEventName::Merged, "lane.merged"),
(LaneEventName::Superseded, "lane.superseded"),
(LaneEventName::Closed, "lane.closed"),
(
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
];
for (event, expected) in cases {
assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected));
}
}
#[test]
fn failure_classes_cover_canonical_taxonomy_wire_values() {
let cases = [
(LaneFailureClass::PromptDelivery, "prompt_delivery"),
(LaneFailureClass::TrustGate, "trust_gate"),
(LaneFailureClass::BranchDivergence, "branch_divergence"),
(LaneFailureClass::Compile, "compile"),
(LaneFailureClass::Test, "test"),
(LaneFailureClass::PluginStartup, "plugin_startup"),
(LaneFailureClass::McpStartup, "mcp_startup"),
(LaneFailureClass::McpHandshake, "mcp_handshake"),
(LaneFailureClass::GatewayRouting, "gateway_routing"),
(LaneFailureClass::ToolRuntime, "tool_runtime"),
(LaneFailureClass::Infra, "infra"),
];
for (failure_class, expected) in cases {
assert_eq!(
serde_json::to_value(failure_class).expect("serialize failure class"),
json!(expected)
);
}
}
#[test]
fn blocked_and_failed_events_reuse_blocker_details() {
let blocker = LaneEventBlocker {
failure_class: LaneFailureClass::McpStartup,
detail: "broken server".to_string(),
};
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
let failed = LaneEvent::failed("2026-04-04T00:00:01Z", &blocker);
assert_eq!(blocked.event, LaneEventName::Blocked);
assert_eq!(blocked.status, LaneEventStatus::Blocked);
assert_eq!(blocked.failure_class, Some(LaneFailureClass::McpStartup));
assert_eq!(failed.event, LaneEventName::Failed);
assert_eq!(failed.status, LaneEventStatus::Failed);
assert_eq!(failed.detail.as_deref(), Some("broken server"));
}
}

View File

@@ -1,46 +1,21 @@
//! Core runtime primitives for the `claw` CLI and supporting crates.
//!
//! This crate owns session persistence, permission evaluation, prompt assembly,
//! MCP plumbing, tool-facing file operations, and the core conversation loop
//! that drives interactive and one-shot turns.
mod bash;
pub mod bash_validation;
mod bootstrap;
mod compact;
mod config;
mod conversation;
mod file_ops;
pub mod green_contract;
mod hooks;
mod json;
mod lane_events;
pub mod lsp_client;
mod mcp;
mod mcp_client;
pub mod mcp_lifecycle_hardened;
mod mcp_stdio;
pub mod mcp_tool_bridge;
mod oauth;
pub mod permission_enforcer;
mod permissions;
pub mod plugin_lifecycle;
mod policy_engine;
mod prompt;
pub mod recovery_recipes;
mod remote;
pub mod sandbox;
mod session;
pub mod session_control;
mod sse;
pub mod stale_branch;
pub mod summary_compression;
pub mod task_packet;
pub mod task_registry;
pub mod team_cron_registry;
pub mod trust_resolver;
mod usage;
pub mod worker_boot;
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
@@ -49,49 +24,37 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnSummary,
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
ToolError, ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use lane_events::{
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
};
pub use mcp_client::{
McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport,
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
};
pub use mcp_lifecycle_hardened::{
McpDegradedReport, McpErrorSurface, McpFailedServer, McpLifecyclePhase, McpLifecycleState,
McpLifecycleValidator, McpPhaseResult,
};
pub use mcp_stdio::{
spawn_mcp_stdio_process, JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
ManagedMcpTool, McpDiscoveryFailure, McpInitializeClientInfo, McpInitializeParams,
McpInitializeResult, McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult,
McpListToolsParams, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
McpResource, McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess,
McpTool, McpToolCallContent, McpToolCallParams, McpToolCallResult, McpToolDiscoveryReport,
UnsupportedMcpServer,
ManagedMcpTool, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult, McpListToolsParams,
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpResource,
McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess, McpTool,
McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
};
pub use oauth::{
clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
@@ -101,54 +64,22 @@ pub use oauth::{
PkceChallengeMethod, PkceCodePair,
};
pub use permissions::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
};
pub use plugin_lifecycle::{
DegradedMode, DiscoveryResult, PluginHealthcheck, PluginLifecycle, PluginLifecycleEvent,
PluginState, ResourceInfo, ServerHealth, ServerStatus, ToolInfo,
};
pub use policy_engine::{
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
RecoveryEvent, RecoveryRecipe, RecoveryResult, RecoveryStep,
};
pub use remote::{
inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
};
pub use sandbox::{
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
FilesystemIsolationMode, LinuxSandboxCommand, SandboxConfig, SandboxDetectionInputs,
SandboxRequest, SandboxStatus,
};
pub use session::{
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
SessionFork,
};
pub use sse::{IncrementalSseParser, SseEvent};
pub use stale_branch::{
apply_policy, check_freshness, BranchFreshness, StaleBranchAction, StaleBranchEvent,
StaleBranchPolicy,
};
pub use task_packet::{validate_packet, TaskPacket, TaskPacketValidationError, ValidatedPacket};
pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver};
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
};
pub use worker_boot::{
Worker, WorkerEvent, WorkerEventKind, WorkerEventPayload, WorkerFailure, WorkerFailureKind,
WorkerPromptTarget, WorkerReadySnapshot, WorkerRegistry, WorkerStatus, WorkerTrustResolution,
};
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {

View File

@@ -1,746 +0,0 @@
//! LSP (Language Server Protocol) client registry for tool dispatch.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
/// Supported LSP actions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LspAction {
Diagnostics,
Hover,
Definition,
References,
Completion,
Symbols,
Format,
}
impl LspAction {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"diagnostics" => Some(Self::Diagnostics),
"hover" => Some(Self::Hover),
"definition" | "goto_definition" => Some(Self::Definition),
"references" | "find_references" => Some(Self::References),
"completion" | "completions" => Some(Self::Completion),
"symbols" | "document_symbols" => Some(Self::Symbols),
"format" | "formatting" => Some(Self::Format),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspDiagnostic {
pub path: String,
pub line: u32,
pub character: u32,
pub severity: String,
pub message: String,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspLocation {
pub path: String,
pub line: u32,
pub character: u32,
pub end_line: Option<u32>,
pub end_character: Option<u32>,
pub preview: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspHoverResult {
pub content: String,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspCompletionItem {
pub label: String,
pub kind: Option<String>,
pub detail: Option<String>,
pub insert_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspSymbol {
pub name: String,
pub kind: String,
pub path: String,
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LspServerStatus {
Connected,
Disconnected,
Starting,
Error,
}
impl std::fmt::Display for LspServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connected => write!(f, "connected"),
Self::Disconnected => write!(f, "disconnected"),
Self::Starting => write!(f, "starting"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspServerState {
pub language: String,
pub status: LspServerStatus,
pub root_path: Option<String>,
pub capabilities: Vec<String>,
pub diagnostics: Vec<LspDiagnostic>,
}
#[derive(Debug, Clone, Default)]
pub struct LspRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
#[derive(Debug, Default)]
struct RegistryInner {
servers: HashMap<String, LspServerState>,
}
impl LspRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(
&self,
language: &str,
status: LspServerStatus,
root_path: Option<&str>,
capabilities: Vec<String>,
) {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.insert(
language.to_owned(),
LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
},
);
}
pub fn get(&self, language: &str) -> Option<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.get(language).cloned()
}
/// Find the appropriate server for a file path based on extension.
pub fn find_server_for_path(&self, path: &str) -> Option<LspServerState> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let language = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"c" | "h" => "c",
"cpp" | "hpp" | "cc" => "cpp",
"rb" => "ruby",
"lua" => "lua",
_ => return None,
};
self.get(language)
}
/// List all registered servers.
pub fn list_servers(&self) -> Vec<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.values().cloned().collect()
}
/// Add diagnostics to a server.
pub fn add_diagnostics(
&self,
language: &str,
diagnostics: Vec<LspDiagnostic>,
) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.extend(diagnostics);
Ok(())
}
/// Get diagnostics for a specific file path.
pub fn get_diagnostics(&self, path: &str) -> Vec<LspDiagnostic> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.filter(|d| d.path == path)
.cloned()
.collect()
}
/// Clear diagnostics for a language server.
pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.clear();
Ok(())
}
/// Disconnect a server.
pub fn disconnect(&self, language: &str) -> Option<LspServerState> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.remove(language)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Dispatch an LSP action and return a structured result.
pub fn dispatch(
&self,
action: &str,
path: Option<&str>,
line: Option<u32>,
character: Option<u32>,
_query: Option<&str>,
) -> Result<serde_json::Value, String> {
let lsp_action =
LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?;
// For diagnostics, we can check existing cached diagnostics
if lsp_action == LspAction::Diagnostics {
if let Some(path) = path {
let diags = self.get_diagnostics(path);
return Ok(serde_json::json!({
"action": "diagnostics",
"path": path,
"diagnostics": diags,
"count": diags.len()
}));
}
// All diagnostics across all servers
let inner = self.inner.lock().expect("lsp registry lock poisoned");
let all_diags: Vec<_> = inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.collect();
return Ok(serde_json::json!({
"action": "diagnostics",
"diagnostics": all_diags,
"count": all_diags.len()
}));
}
// For other actions, we need a connected server for the given file
let path = path.ok_or("path is required for this LSP action")?;
let server = self
.find_server_for_path(path)
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
if server.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {})",
server.language, server.status
));
}
// Return structured placeholder — actual LSP JSON-RPC calls would
// go through the real LSP process here.
Ok(serde_json::json!({
"action": action,
"path": path,
"line": line,
"character": character,
"language": server.language,
"status": "dispatched",
"message": format!("LSP {} dispatched to {} server", action, server.language)
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registers_and_retrieves_server() {
let registry = LspRegistry::new();
registry.register(
"rust",
LspServerStatus::Connected,
Some("/workspace"),
vec!["hover".into(), "completion".into()],
);
let server = registry.get("rust").expect("should exist");
assert_eq!(server.language, "rust");
assert_eq!(server.status, LspServerStatus::Connected);
assert_eq!(server.capabilities.len(), 2);
}
#[test]
fn finds_server_by_file_extension() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("typescript", LspServerStatus::Connected, None, vec![]);
let rs_server = registry.find_server_for_path("src/main.rs").unwrap();
assert_eq!(rs_server.language, "rust");
let ts_server = registry.find_server_for_path("src/index.ts").unwrap();
assert_eq!(ts_server.language, "typescript");
assert!(registry.find_server_for_path("data.csv").is_none());
}
#[test]
fn manages_diagnostics() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/main.rs".into(),
line: 10,
character: 5,
severity: "error".into(),
message: "mismatched types".into(),
source: Some("rust-analyzer".into()),
}],
)
.unwrap();
let diags = registry.get_diagnostics("src/main.rs");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "mismatched types");
registry.clear_diagnostics("rust").unwrap();
assert!(registry.get_diagnostics("src/main.rs").is_empty());
}
#[test]
fn dispatches_diagnostics_action() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/lib.rs".into(),
line: 1,
character: 0,
severity: "warning".into(),
message: "unused import".into(),
source: None,
}],
)
.unwrap();
let result = registry
.dispatch("diagnostics", Some("src/lib.rs"), None, None, None)
.unwrap();
assert_eq!(result["count"], 1);
}
#[test]
fn dispatches_hover_action() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
let result = registry
.dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None)
.unwrap();
assert_eq!(result["action"], "hover");
assert_eq!(result["language"], "rust");
}
#[test]
fn rejects_action_on_disconnected_server() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Disconnected, None, vec![]);
assert!(registry
.dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None)
.is_err());
}
#[test]
fn rejects_unknown_action() {
let registry = LspRegistry::new();
assert!(registry
.dispatch("unknown_action", Some("file.rs"), None, None, None)
.is_err());
}
#[test]
fn disconnects_server() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
assert_eq!(registry.len(), 1);
let removed = registry.disconnect("rust");
assert!(removed.is_some());
assert!(registry.is_empty());
}
#[test]
fn lsp_action_from_str_all_aliases() {
// given
let cases = [
("diagnostics", Some(LspAction::Diagnostics)),
("hover", Some(LspAction::Hover)),
("definition", Some(LspAction::Definition)),
("goto_definition", Some(LspAction::Definition)),
("references", Some(LspAction::References)),
("find_references", Some(LspAction::References)),
("completion", Some(LspAction::Completion)),
("completions", Some(LspAction::Completion)),
("symbols", Some(LspAction::Symbols)),
("document_symbols", Some(LspAction::Symbols)),
("format", Some(LspAction::Format)),
("formatting", Some(LspAction::Format)),
("unknown", None),
];
// when
let resolved: Vec<_> = cases
.into_iter()
.map(|(input, expected)| (input, LspAction::from_str(input), expected))
.collect();
// then
for (input, actual, expected) in resolved {
assert_eq!(actual, expected, "unexpected action resolution for {input}");
}
}
#[test]
fn lsp_server_status_display_all_variants() {
// given
let cases = [
(LspServerStatus::Connected, "connected"),
(LspServerStatus::Disconnected, "disconnected"),
(LspServerStatus::Starting, "starting"),
(LspServerStatus::Error, "error"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("connected".to_string(), "connected"),
("disconnected".to_string(), "disconnected"),
("starting".to_string(), "starting"),
("error".to_string(), "error"),
]
);
}
#[test]
fn dispatch_diagnostics_without_path_aggregates() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("python", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/lib.rs".into(),
line: 1,
character: 0,
severity: "warning".into(),
message: "unused import".into(),
source: Some("rust-analyzer".into()),
}],
)
.expect("rust diagnostics should add");
registry
.add_diagnostics(
"python",
vec![LspDiagnostic {
path: "script.py".into(),
line: 2,
character: 4,
severity: "error".into(),
message: "undefined name".into(),
source: Some("pyright".into()),
}],
)
.expect("python diagnostics should add");
// when
let result = registry
.dispatch("diagnostics", None, None, None, None)
.expect("aggregate diagnostics should work");
// then
assert_eq!(result["action"], "diagnostics");
assert_eq!(result["count"], 2);
assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2));
}
#[test]
fn dispatch_non_diagnostics_requires_path() {
// given
let registry = LspRegistry::new();
// when
let result = registry.dispatch("hover", None, Some(1), Some(0), None);
// then
assert_eq!(
result.expect_err("path should be required"),
"path is required for this LSP action"
);
}
#[test]
fn dispatch_no_server_for_path_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None);
// then
let error = result.expect_err("missing server should fail");
assert!(error.contains("no LSP server available for path: notes.md"));
}
#[test]
fn dispatch_disconnected_server_error_payload() {
// given
let registry = LspRegistry::new();
registry.register("typescript", LspServerStatus::Disconnected, None, vec![]);
// when
let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None);
// then
let error = result.expect_err("disconnected server should fail");
assert!(error.contains("typescript"));
assert!(error.contains("disconnected"));
}
#[test]
fn find_server_for_all_extensions() {
// given
let registry = LspRegistry::new();
for language in [
"rust",
"typescript",
"javascript",
"python",
"go",
"java",
"c",
"cpp",
"ruby",
"lua",
] {
registry.register(language, LspServerStatus::Connected, None, vec![]);
}
let cases = [
("src/main.rs", "rust"),
("src/index.ts", "typescript"),
("src/view.tsx", "typescript"),
("src/app.js", "javascript"),
("src/app.jsx", "javascript"),
("script.py", "python"),
("main.go", "go"),
("Main.java", "java"),
("native.c", "c"),
("native.h", "c"),
("native.cpp", "cpp"),
("native.hpp", "cpp"),
("native.cc", "cpp"),
("script.rb", "ruby"),
("script.lua", "lua"),
];
// when
let resolved: Vec<_> = cases
.into_iter()
.map(|(path, expected)| {
(
path,
registry
.find_server_for_path(path)
.map(|server| server.language),
expected,
)
})
.collect();
// then
for (path, actual, expected) in resolved {
assert_eq!(
actual.as_deref(),
Some(expected),
"unexpected mapping for {path}"
);
}
}
#[test]
fn find_server_for_path_no_extension() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
// when
let result = registry.find_server_for_path("Makefile");
// then
assert!(result.is_none());
}
#[test]
fn list_servers_with_multiple() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("typescript", LspServerStatus::Starting, None, vec![]);
registry.register("python", LspServerStatus::Error, None, vec![]);
// when
let servers = registry.list_servers();
// then
assert_eq!(servers.len(), 3);
assert!(servers.iter().any(|server| server.language == "rust"));
assert!(servers.iter().any(|server| server.language == "typescript"));
assert!(servers.iter().any(|server| server.language == "python"));
}
#[test]
fn get_missing_server_returns_none() {
// given
let registry = LspRegistry::new();
// when
let server = registry.get("missing");
// then
assert!(server.is_none());
}
#[test]
fn add_diagnostics_missing_language_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.add_diagnostics("missing", vec![]);
// then
let error = result.expect_err("missing language should fail");
assert!(error.contains("LSP server not found for language: missing"));
}
#[test]
fn get_diagnostics_across_servers() {
// given
let registry = LspRegistry::new();
let shared_path = "shared/file.txt";
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("python", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: shared_path.into(),
line: 4,
character: 1,
severity: "warning".into(),
message: "warn".into(),
source: None,
}],
)
.expect("rust diagnostics should add");
registry
.add_diagnostics(
"python",
vec![LspDiagnostic {
path: shared_path.into(),
line: 8,
character: 3,
severity: "error".into(),
message: "err".into(),
source: None,
}],
)
.expect("python diagnostics should add");
// when
let diagnostics = registry.get_diagnostics(shared_path);
// then
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics
.iter()
.any(|diagnostic| diagnostic.message == "warn"));
assert!(diagnostics
.iter()
.any(|diagnostic| diagnostic.message == "err"));
}
#[test]
fn clear_diagnostics_missing_language_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.clear_diagnostics("missing");
// then
let error = result.expect_err("missing language should fail");
assert!(error.contains("LSP server not found for language: missing"));
}
}

View File

@@ -84,13 +84,10 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
let rendered = match &config.config {
McpServerConfig::Stdio(stdio) => format!(
"stdio|{}|{}|{}|{}",
"stdio|{}|{}|{}",
stdio.command,
render_command_signature(&stdio.args),
render_env_signature(&stdio.env),
stdio
.tool_call_timeout_ms
.map_or_else(String::new, |timeout_ms| timeout_ms.to_string())
render_env_signature(&stdio.env)
),
McpServerConfig::Sse(remote) => format!(
"sse|{}|{}|{}|{}",
@@ -248,7 +245,6 @@ mod tests {
command: "uvx".to_string(),
args: vec!["mcp-server".to_string()],
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
tool_call_timeout_ms: None,
});
assert_eq!(
mcp_server_signature(&stdio),

View File

@@ -3,8 +3,6 @@ use std::collections::BTreeMap;
use crate::config::{McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig};
use crate::mcp::{mcp_server_signature, mcp_tool_prefix, normalize_name_for_mcp};
pub const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS: u64 = 60_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpClientTransport {
Stdio(McpStdioTransport),
@@ -20,7 +18,6 @@ pub struct McpStdioTransport {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub tool_call_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -78,7 +75,6 @@ impl McpClientTransport {
command: config.command.clone(),
args: config.args.clone(),
env: config.env.clone(),
tool_call_timeout_ms: config.tool_call_timeout_ms,
}),
McpServerConfig::Sse(config) => Self::Sse(McpRemoteTransport {
url: config.url.clone(),
@@ -101,22 +97,16 @@ impl McpClientTransport {
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
name: config.name.clone(),
}),
McpServerConfig::ManagedProxy(config) => Self::ManagedProxy(McpManagedProxyTransport {
url: config.url.clone(),
id: config.id.clone(),
}),
McpServerConfig::ManagedProxy(config) => {
Self::ManagedProxy(McpManagedProxyTransport {
url: config.url.clone(),
id: config.id.clone(),
})
}
}
}
}
impl McpStdioTransport {
#[must_use]
pub fn resolved_tool_call_timeout_ms(&self) -> u64 {
self.tool_call_timeout_ms
.unwrap_or(DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS)
}
}
impl McpClientAuth {
#[must_use]
pub fn from_oauth(oauth: Option<McpOAuthConfig>) -> Self {
@@ -148,7 +138,6 @@ mod tests {
command: "uvx".to_string(),
args: vec!["mcp-server".to_string()],
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
tool_call_timeout_ms: Some(15_000),
}),
};
@@ -167,7 +156,6 @@ mod tests {
transport.env.get("TOKEN").map(String::as_str),
Some("secret")
);
assert_eq!(transport.tool_call_timeout_ms, Some(15_000));
}
other => panic!("expected stdio transport, got {other:?}"),
}

View File

@@ -1,839 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpLifecyclePhase {
ConfigLoad,
ServerRegistration,
SpawnConnect,
InitializeHandshake,
ToolDiscovery,
ResourceDiscovery,
Ready,
Invocation,
ErrorSurfacing,
Shutdown,
Cleanup,
}
impl McpLifecyclePhase {
#[must_use]
pub fn all() -> [Self; 11] {
[
Self::ConfigLoad,
Self::ServerRegistration,
Self::SpawnConnect,
Self::InitializeHandshake,
Self::ToolDiscovery,
Self::ResourceDiscovery,
Self::Ready,
Self::Invocation,
Self::ErrorSurfacing,
Self::Shutdown,
Self::Cleanup,
]
}
}
impl std::fmt::Display for McpLifecyclePhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfigLoad => write!(f, "config_load"),
Self::ServerRegistration => write!(f, "server_registration"),
Self::SpawnConnect => write!(f, "spawn_connect"),
Self::InitializeHandshake => write!(f, "initialize_handshake"),
Self::ToolDiscovery => write!(f, "tool_discovery"),
Self::ResourceDiscovery => write!(f, "resource_discovery"),
Self::Ready => write!(f, "ready"),
Self::Invocation => write!(f, "invocation"),
Self::ErrorSurfacing => write!(f, "error_surfacing"),
Self::Shutdown => write!(f, "shutdown"),
Self::Cleanup => write!(f, "cleanup"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpErrorSurface {
pub phase: McpLifecyclePhase,
pub server_name: Option<String>,
pub message: String,
pub context: BTreeMap<String, String>,
pub recoverable: bool,
pub timestamp: u64,
}
impl McpErrorSurface {
#[must_use]
pub fn new(
phase: McpLifecyclePhase,
server_name: Option<String>,
message: impl Into<String>,
context: BTreeMap<String, String>,
recoverable: bool,
) -> Self {
Self {
phase,
server_name,
message: message.into(),
context,
recoverable,
timestamp: now_secs(),
}
}
}
impl std::fmt::Display for McpErrorSurface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MCP lifecycle error during {}: {}",
self.phase, self.message
)?;
if let Some(server_name) = &self.server_name {
write!(f, " (server: {server_name})")?;
}
if !self.context.is_empty() {
write!(f, " with context {:?}", self.context)?;
}
if self.recoverable {
write!(f, " [recoverable]")?;
}
Ok(())
}
}
impl std::error::Error for McpErrorSurface {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpPhaseResult {
Success {
phase: McpLifecyclePhase,
duration: Duration,
},
Failure {
phase: McpLifecyclePhase,
error: McpErrorSurface,
},
Timeout {
phase: McpLifecyclePhase,
waited: Duration,
error: McpErrorSurface,
},
}
impl McpPhaseResult {
#[must_use]
pub fn phase(&self) -> McpLifecyclePhase {
match self {
Self::Success { phase, .. }
| Self::Failure { phase, .. }
| Self::Timeout { phase, .. } => *phase,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct McpLifecycleState {
current_phase: Option<McpLifecyclePhase>,
phase_errors: BTreeMap<McpLifecyclePhase, Vec<McpErrorSurface>>,
phase_timestamps: BTreeMap<McpLifecyclePhase, u64>,
phase_results: Vec<McpPhaseResult>,
}
impl McpLifecycleState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn current_phase(&self) -> Option<McpLifecyclePhase> {
self.current_phase
}
#[must_use]
pub fn errors_for_phase(&self, phase: McpLifecyclePhase) -> &[McpErrorSurface] {
self.phase_errors
.get(&phase)
.map(Vec::as_slice)
.unwrap_or(&[])
}
#[must_use]
pub fn results(&self) -> &[McpPhaseResult] {
&self.phase_results
}
#[must_use]
pub fn phase_timestamps(&self) -> &BTreeMap<McpLifecyclePhase, u64> {
&self.phase_timestamps
}
#[must_use]
pub fn phase_timestamp(&self, phase: McpLifecyclePhase) -> Option<u64> {
self.phase_timestamps.get(&phase).copied()
}
fn record_phase(&mut self, phase: McpLifecyclePhase) {
self.current_phase = Some(phase);
self.phase_timestamps.insert(phase, now_secs());
}
fn record_error(&mut self, error: McpErrorSurface) {
self.phase_errors
.entry(error.phase)
.or_default()
.push(error);
}
fn record_result(&mut self, result: McpPhaseResult) {
self.phase_results.push(result);
}
fn can_resume_after_error(&self) -> bool {
match self.phase_results.last() {
Some(McpPhaseResult::Failure { error, .. } | McpPhaseResult::Timeout { error, .. }) => {
error.recoverable
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpFailedServer {
pub server_name: String,
pub phase: McpLifecyclePhase,
pub error: McpErrorSurface,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpDegradedReport {
pub working_servers: Vec<String>,
pub failed_servers: Vec<McpFailedServer>,
pub available_tools: Vec<String>,
pub missing_tools: Vec<String>,
}
impl McpDegradedReport {
#[must_use]
pub fn new(
working_servers: Vec<String>,
failed_servers: Vec<McpFailedServer>,
available_tools: Vec<String>,
expected_tools: Vec<String>,
) -> Self {
let working_servers = dedupe_sorted(working_servers);
let available_tools = dedupe_sorted(available_tools);
let available_tool_set: BTreeSet<_> = available_tools.iter().cloned().collect();
let expected_tools = dedupe_sorted(expected_tools);
let missing_tools = expected_tools
.into_iter()
.filter(|tool| !available_tool_set.contains(tool))
.collect();
Self {
working_servers,
failed_servers,
available_tools,
missing_tools,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct McpLifecycleValidator {
state: McpLifecycleState,
}
impl McpLifecycleValidator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn state(&self) -> &McpLifecycleState {
&self.state
}
#[must_use]
pub fn validate_phase_transition(from: McpLifecyclePhase, to: McpLifecyclePhase) -> bool {
match (from, to) {
(McpLifecyclePhase::ConfigLoad, McpLifecyclePhase::ServerRegistration)
| (McpLifecyclePhase::ServerRegistration, McpLifecyclePhase::SpawnConnect)
| (McpLifecyclePhase::SpawnConnect, McpLifecyclePhase::InitializeHandshake)
| (McpLifecyclePhase::InitializeHandshake, McpLifecyclePhase::ToolDiscovery)
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::ResourceDiscovery)
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ResourceDiscovery, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation)
| (McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Shutdown)
| (McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup) => true,
(_, McpLifecyclePhase::Shutdown) => from != McpLifecyclePhase::Cleanup,
(_, McpLifecyclePhase::ErrorSurfacing) => {
from != McpLifecyclePhase::Cleanup && from != McpLifecyclePhase::Shutdown
}
_ => false,
}
}
pub fn run_phase(&mut self, phase: McpLifecyclePhase) -> McpPhaseResult {
let started = Instant::now();
if let Some(current_phase) = self.state.current_phase() {
if current_phase == McpLifecyclePhase::ErrorSurfacing
&& phase == McpLifecyclePhase::Ready
&& !self.state.can_resume_after_error()
{
return self.record_failure(McpErrorSurface::new(
phase,
None,
"cannot return to ready after a non-recoverable MCP lifecycle failure",
BTreeMap::from([
("from".to_string(), current_phase.to_string()),
("to".to_string(), phase.to_string()),
]),
false,
));
}
if !Self::validate_phase_transition(current_phase, phase) {
return self.record_failure(McpErrorSurface::new(
phase,
None,
format!("invalid MCP lifecycle transition from {current_phase} to {phase}"),
BTreeMap::from([
("from".to_string(), current_phase.to_string()),
("to".to_string(), phase.to_string()),
]),
false,
));
}
} else if phase != McpLifecyclePhase::ConfigLoad {
return self.record_failure(McpErrorSurface::new(
phase,
None,
format!("invalid initial MCP lifecycle phase {phase}"),
BTreeMap::from([("phase".to_string(), phase.to_string())]),
false,
));
}
self.state.record_phase(phase);
let result = McpPhaseResult::Success {
phase,
duration: started.elapsed(),
};
self.state.record_result(result.clone());
result
}
pub fn record_failure(&mut self, error: McpErrorSurface) -> McpPhaseResult {
let phase = error.phase;
self.state.record_error(error.clone());
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
let result = McpPhaseResult::Failure { phase, error };
self.state.record_result(result.clone());
result
}
pub fn record_timeout(
&mut self,
phase: McpLifecyclePhase,
waited: Duration,
server_name: Option<String>,
mut context: BTreeMap<String, String>,
) -> McpPhaseResult {
context.insert("waited_ms".to_string(), waited.as_millis().to_string());
let error = McpErrorSurface::new(
phase,
server_name,
format!(
"MCP lifecycle phase {phase} timed out after {} ms",
waited.as_millis()
),
context,
true,
);
self.state.record_error(error.clone());
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
let result = McpPhaseResult::Timeout {
phase,
waited,
error,
};
self.state.record_result(result.clone());
result
}
}
fn dedupe_sorted(mut values: Vec<String>) -> Vec<String> {
values.sort();
values.dedup();
values
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn phase_display_matches_serde_name() {
// given
let phases = McpLifecyclePhase::all();
// when
let serialized = phases
.into_iter()
.map(|phase| {
(
phase.to_string(),
serde_json::to_value(phase).expect("serialize phase"),
)
})
.collect::<Vec<_>>();
// then
for (display, json_value) in serialized {
assert_eq!(json_value, json!(display));
}
}
#[test]
fn given_startup_path_when_running_to_cleanup_then_each_control_transition_succeeds() {
// given
let mut validator = McpLifecycleValidator::new();
let phases = [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Invocation,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Shutdown,
McpLifecyclePhase::Cleanup,
];
// when
let results = phases
.into_iter()
.map(|phase| validator.run_phase(phase))
.collect::<Vec<_>>();
// then
assert!(results
.iter()
.all(|result| matches!(result, McpPhaseResult::Success { .. })));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Cleanup)
);
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Invocation,
McpLifecyclePhase::Shutdown,
McpLifecyclePhase::Cleanup,
] {
assert!(validator.state().phase_timestamp(phase).is_some());
}
}
#[test]
fn given_tool_discovery_when_resource_discovery_is_skipped_then_ready_is_still_allowed() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
assert!(matches!(result, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Ready)
);
}
#[test]
fn validates_expected_phase_transitions() {
// given
let valid_transitions = [
(
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
),
(
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
),
(
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
),
(
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
),
(
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
),
(McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready),
(
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
),
(McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation),
(McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready),
(McpLifecyclePhase::Ready, McpLifecyclePhase::Shutdown),
(
McpLifecyclePhase::Invocation,
McpLifecyclePhase::ErrorSurfacing,
),
(
McpLifecyclePhase::ErrorSurfacing,
McpLifecyclePhase::Shutdown,
),
(McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup),
];
// when / then
for (from, to) in valid_transitions {
assert!(McpLifecycleValidator::validate_phase_transition(from, to));
}
assert!(!McpLifecycleValidator::validate_phase_transition(
McpLifecyclePhase::Ready,
McpLifecyclePhase::ConfigLoad,
));
assert!(!McpLifecycleValidator::validate_phase_transition(
McpLifecyclePhase::Cleanup,
McpLifecyclePhase::Ready,
));
}
#[test]
fn given_invalid_transition_when_running_phase_then_structured_failure_is_recorded() {
// given
let mut validator = McpLifecycleValidator::new();
let _ = validator.run_phase(McpLifecyclePhase::ConfigLoad);
let _ = validator.run_phase(McpLifecyclePhase::ServerRegistration);
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
match result {
McpPhaseResult::Failure { phase, error } => {
assert_eq!(phase, McpLifecyclePhase::Ready);
assert!(!error.recoverable);
assert_eq!(error.phase, McpLifecyclePhase::Ready);
assert_eq!(
error.context.get("from").map(String::as_str),
Some("server_registration")
);
assert_eq!(error.context.get("to").map(String::as_str), Some("ready"));
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
assert_eq!(
validator
.state()
.errors_for_phase(McpLifecyclePhase::Ready)
.len(),
1
);
}
#[test]
fn given_each_phase_when_failure_is_recorded_then_error_is_tracked_per_phase() {
// given
let mut validator = McpLifecycleValidator::new();
// when / then
for phase in McpLifecyclePhase::all() {
let result = validator.record_failure(McpErrorSurface::new(
phase,
Some("alpha".to_string()),
format!("failure at {phase}"),
BTreeMap::from([("server".to_string(), "alpha".to_string())]),
phase == McpLifecyclePhase::ResourceDiscovery,
));
match result {
McpPhaseResult::Failure { phase: failed_phase, error } => {
assert_eq!(failed_phase, phase);
assert_eq!(error.phase, phase);
assert_eq!(
error.recoverable,
phase == McpLifecyclePhase::ResourceDiscovery
);
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(validator.state().errors_for_phase(phase).len(), 1);
}
}
#[test]
fn given_spawn_connect_timeout_when_recorded_then_waited_duration_is_preserved() {
// given
let mut validator = McpLifecycleValidator::new();
let waited = Duration::from_millis(250);
// when
let result = validator.record_timeout(
McpLifecyclePhase::SpawnConnect,
waited,
Some("alpha".to_string()),
BTreeMap::from([("attempt".to_string(), "1".to_string())]),
);
// then
match result {
McpPhaseResult::Timeout {
phase,
waited: actual,
error,
} => {
assert_eq!(phase, McpLifecyclePhase::SpawnConnect);
assert_eq!(actual, waited);
assert!(error.recoverable);
assert_eq!(error.server_name.as_deref(), Some("alpha"));
}
other => panic!("expected timeout result, got {other:?}"),
}
let errors = validator
.state()
.errors_for_phase(McpLifecyclePhase::SpawnConnect);
assert_eq!(errors.len(), 1);
assert_eq!(
errors[0].context.get("waited_ms").map(String::as_str),
Some("250")
);
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
}
#[test]
fn given_partial_server_health_when_building_degraded_report_then_missing_tools_are_reported() {
// given
let failed = vec![McpFailedServer {
server_name: "broken".to_string(),
phase: McpLifecyclePhase::InitializeHandshake,
error: McpErrorSurface::new(
McpLifecyclePhase::InitializeHandshake,
Some("broken".to_string()),
"initialize failed",
BTreeMap::from([("reason".to_string(), "broken pipe".to_string())]),
false,
),
}];
// when
let report = McpDegradedReport::new(
vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()],
failed,
vec![
"alpha.echo".to_string(),
"beta.search".to_string(),
"alpha.echo".to_string(),
],
vec![
"alpha.echo".to_string(),
"beta.search".to_string(),
"broken.fetch".to_string(),
],
);
// then
assert_eq!(
report.working_servers,
vec!["alpha".to_string(), "beta".to_string()]
);
assert_eq!(report.failed_servers.len(), 1);
assert_eq!(report.failed_servers[0].server_name, "broken");
assert_eq!(
report.available_tools,
vec!["alpha.echo".to_string(), "beta.search".to_string()]
);
assert_eq!(report.missing_tools, vec!["broken.fetch".to_string()]);
}
#[test]
fn given_failure_during_resource_discovery_when_shutting_down_then_cleanup_still_succeeds() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::ResourceDiscovery,
Some("alpha".to_string()),
"resource listing failed",
BTreeMap::from([("reason".to_string(), "timeout".to_string())]),
true,
));
// when
let shutdown = validator.run_phase(McpLifecyclePhase::Shutdown);
let cleanup = validator.run_phase(McpLifecyclePhase::Cleanup);
// then
assert!(matches!(shutdown, McpPhaseResult::Success { .. }));
assert!(matches!(cleanup, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Cleanup)
);
assert!(validator
.state()
.phase_timestamp(McpLifecyclePhase::ErrorSurfacing)
.is_some());
}
#[test]
fn error_surface_display_includes_phase_server_and_recoverable_flag() {
// given
let error = McpErrorSurface::new(
McpLifecyclePhase::SpawnConnect,
Some("alpha".to_string()),
"process exited early",
BTreeMap::from([("exit_code".to_string(), "1".to_string())]),
true,
);
// when
let rendered = error.to_string();
// then
assert!(rendered.contains("spawn_connect"));
assert!(rendered.contains("process exited early"));
assert!(rendered.contains("server: alpha"));
assert!(rendered.contains("recoverable"));
let trait_object: &dyn std::error::Error = &error;
assert_eq!(trait_object.to_string(), rendered);
}
#[test]
fn given_nonrecoverable_failure_when_returning_to_ready_then_validator_rejects_resume() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::Ready,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::Invocation,
Some("alpha".to_string()),
"tool call corrupted the session",
BTreeMap::from([("reason".to_string(), "invalid frame".to_string())]),
false,
));
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
match result {
McpPhaseResult::Failure { phase, error } => {
assert_eq!(phase, McpLifecyclePhase::Ready);
assert!(!error.recoverable);
assert!(error.message.contains("non-recoverable"));
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
}
#[test]
fn given_recoverable_failure_when_returning_to_ready_then_validator_allows_resume() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::Ready,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::Invocation,
Some("alpha".to_string()),
"tool call failed but can be retried",
BTreeMap::from([("reason".to_string(), "upstream timeout".to_string())]),
true,
));
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
assert!(matches!(result, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Ready)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,912 +0,0 @@
//! Bridge between MCP tool surface (ListMcpResources, ReadMcpResource, McpAuth, MCP)
//! and the existing McpServerManager runtime.
//!
//! Provides a stateful client registry that tool handlers can use to
//! connect to MCP servers and invoke their capabilities.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use crate::mcp::mcp_tool_name;
use crate::mcp_stdio::McpServerManager;
use serde::{Deserialize, Serialize};
/// Status of a managed MCP server connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpConnectionStatus {
Disconnected,
Connecting,
Connected,
AuthRequired,
Error,
}
impl std::fmt::Display for McpConnectionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disconnected => write!(f, "disconnected"),
Self::Connecting => write!(f, "connecting"),
Self::Connected => write!(f, "connected"),
Self::AuthRequired => write!(f, "auth_required"),
Self::Error => write!(f, "error"),
}
}
}
/// Metadata about an MCP resource.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResourceInfo {
pub uri: String,
pub name: String,
pub description: Option<String>,
pub mime_type: Option<String>,
}
/// Metadata about an MCP tool exposed by a server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo {
pub name: String,
pub description: Option<String>,
pub input_schema: Option<serde_json::Value>,
}
/// Tracked state of an MCP server connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerState {
pub server_name: String,
pub status: McpConnectionStatus,
pub tools: Vec<McpToolInfo>,
pub resources: Vec<McpResourceInfo>,
pub server_info: Option<String>,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct McpToolRegistry {
inner: Arc<Mutex<HashMap<String, McpServerState>>>,
manager: Arc<OnceLock<Arc<Mutex<McpServerManager>>>>,
}
impl McpToolRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_manager(
&self,
manager: Arc<Mutex<McpServerManager>>,
) -> Result<(), Arc<Mutex<McpServerManager>>> {
self.manager.set(manager)
}
pub fn register_server(
&self,
server_name: &str,
status: McpConnectionStatus,
tools: Vec<McpToolInfo>,
resources: Vec<McpResourceInfo>,
server_info: Option<String>,
) {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.insert(
server_name.to_owned(),
McpServerState {
server_name: server_name.to_owned(),
status,
tools,
resources,
server_info,
error_message: None,
},
);
}
pub fn get_server(&self, server_name: &str) -> Option<McpServerState> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.get(server_name).cloned()
}
pub fn list_servers(&self) -> Vec<McpServerState> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.values().cloned().collect()
}
pub fn list_resources(&self, server_name: &str) -> Result<Vec<McpResourceInfo>, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
match inner.get(server_name) {
Some(state) => {
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
Ok(state.resources.clone())
}
None => Err(format!("server '{}' not found", server_name)),
}
}
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<McpResourceInfo, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
state
.resources
.iter()
.find(|r| r.uri == uri)
.cloned()
.ok_or_else(|| format!("resource '{}' not found on server '{}'", uri, server_name))
}
pub fn list_tools(&self, server_name: &str) -> Result<Vec<McpToolInfo>, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
match inner.get(server_name) {
Some(state) => {
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
Ok(state.tools.clone())
}
None => Err(format!("server '{}' not found", server_name)),
}
}
fn spawn_tool_call(
manager: Arc<Mutex<McpServerManager>>,
qualified_tool_name: String,
arguments: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let join_handle = std::thread::Builder::new()
.name(format!("mcp-tool-call-{qualified_tool_name}"))
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("failed to create MCP tool runtime: {error}"))?;
runtime.block_on(async move {
let response = {
let mut manager = manager
.lock()
.map_err(|_| "mcp server manager lock poisoned".to_string())?;
manager
.discover_tools()
.await
.map_err(|error| error.to_string())?;
let response = manager
.call_tool(&qualified_tool_name, arguments)
.await
.map_err(|error| error.to_string());
let shutdown = manager.shutdown().await.map_err(|error| error.to_string());
match (response, shutdown) {
(Ok(response), Ok(())) => Ok(response),
(Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
(Ok(_), Err(error)) => Err(error),
}
}?;
if let Some(error) = response.error {
return Err(format!(
"MCP server returned JSON-RPC error for tools/call: {} ({})",
error.message, error.code
));
}
let result = response.result.ok_or_else(|| {
"MCP server returned no result for tools/call".to_string()
})?;
serde_json::to_value(result)
.map_err(|error| format!("failed to serialize MCP tool result: {error}"))
})
})
.map_err(|error| format!("failed to spawn MCP tool call thread: {error}"))?;
join_handle.join().map_err(|panic_payload| {
if let Some(message) = panic_payload.downcast_ref::<&str>() {
format!("MCP tool call thread panicked: {message}")
} else if let Some(message) = panic_payload.downcast_ref::<String>() {
format!("MCP tool call thread panicked: {message}")
} else {
"MCP tool call thread panicked".to_string()
}
})?
}
pub fn call_tool(
&self,
server_name: &str,
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
if !state.tools.iter().any(|t| t.name == tool_name) {
return Err(format!(
"tool '{}' not found on server '{}'",
tool_name, server_name
));
}
drop(inner);
let manager = self
.manager
.get()
.cloned()
.ok_or_else(|| "MCP server manager is not configured".to_string())?;
Self::spawn_tool_call(
manager,
mcp_tool_name(server_name, tool_name),
(!arguments.is_null()).then(|| arguments.clone()),
)
}
/// Set auth status for a server.
pub fn set_auth_status(
&self,
server_name: &str,
status: McpConnectionStatus,
) -> Result<(), String> {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get_mut(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
state.status = status;
Ok(())
}
/// Disconnect / remove a server.
pub fn disconnect(&self, server_name: &str) -> Option<McpServerState> {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.remove(server_name)
}
/// Number of registered servers.
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
use crate::config::{
ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
};
fn temp_dir() -> PathBuf {
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
let unique_id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("runtime-mcp-tool-bridge-{nanos}-{unique_id}"))
}
fn cleanup_script(script_path: &Path) {
if let Some(root) = script_path.parent() {
let _ = fs::remove_dir_all(root);
}
}
fn write_bridge_mcp_server_script() -> PathBuf {
let root = temp_dir();
fs::create_dir_all(&root).expect("temp dir");
let script_path = root.join("bridge-mcp-server.py");
let script = [
"#!/usr/bin/env python3",
"import json, os, sys",
"LABEL = os.environ.get('MCP_SERVER_LABEL', 'server')",
"LOG_PATH = os.environ.get('MCP_LOG_PATH')",
"",
"def log(method):",
" if LOG_PATH:",
" with open(LOG_PATH, 'a', encoding='utf-8') as handle:",
" handle.write(f'{method}\\n')",
"",
"def read_message():",
" header = b''",
r" while not header.endswith(b'\r\n\r\n'):",
" chunk = sys.stdin.buffer.read(1)",
" if not chunk:",
" return None",
" header += chunk",
" length = 0",
r" for line in header.decode().split('\r\n'):",
r" if line.lower().startswith('content-length:'):",
r" length = int(line.split(':', 1)[1].strip())",
" payload = sys.stdin.buffer.read(length)",
" return json.loads(payload.decode())",
"",
"def send_message(message):",
" payload = json.dumps(message).encode()",
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
" sys.stdout.buffer.flush()",
"",
"while True:",
" request = read_message()",
" if request is None:",
" break",
" method = request['method']",
" log(method)",
" if method == 'initialize':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'protocolVersion': request['params']['protocolVersion'],",
" 'capabilities': {'tools': {}},",
" 'serverInfo': {'name': LABEL, 'version': '1.0.0'}",
" }",
" })",
" elif method == 'tools/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'tools': [",
" {",
" 'name': 'echo',",
" 'description': f'Echo tool for {LABEL}',",
" 'inputSchema': {",
" 'type': 'object',",
" 'properties': {'text': {'type': 'string'}},",
" 'required': ['text']",
" }",
" }",
" ]",
" }",
" })",
" elif method == 'tools/call':",
" args = request['params'].get('arguments') or {}",
" text = args.get('text', '')",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'content': [{'type': 'text', 'text': f'{LABEL}:{text}'}],",
" 'structuredContent': {'server': LABEL, 'echoed': text},",
" 'isError': False",
" }",
" })",
" else:",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'error': {'code': -32601, 'message': f'unknown method: {method}'},",
" })",
"",
]
.join("\n");
fs::write(&script_path, script).expect("write script");
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("chmod");
script_path
}
fn manager_server_config(
script_path: &Path,
server_name: &str,
log_path: &Path,
) -> ScopedMcpServerConfig {
ScopedMcpServerConfig {
scope: ConfigSource::Local,
config: McpServerConfig::Stdio(McpStdioServerConfig {
command: "python3".to_string(),
args: vec![script_path.to_string_lossy().into_owned()],
env: BTreeMap::from([
("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
(
"MCP_LOG_PATH".to_string(),
log_path.to_string_lossy().into_owned(),
),
]),
tool_call_timeout_ms: Some(1_000),
}),
}
}
#[test]
fn registers_and_retrieves_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"test-server",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "greet".into(),
description: Some("Greet someone".into()),
input_schema: None,
}],
vec![McpResourceInfo {
uri: "res://data".into(),
name: "Data".into(),
description: None,
mime_type: Some("application/json".into()),
}],
Some("TestServer v1.0".into()),
);
let server = registry.get_server("test-server").expect("should exist");
assert_eq!(server.status, McpConnectionStatus::Connected);
assert_eq!(server.tools.len(), 1);
assert_eq!(server.resources.len(), 1);
}
#[test]
fn lists_resources_from_connected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![],
vec![McpResourceInfo {
uri: "res://alpha".into(),
name: "Alpha".into(),
description: None,
mime_type: None,
}],
None,
);
let resources = registry.list_resources("srv").expect("should succeed");
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].uri, "res://alpha");
}
#[test]
fn rejects_resource_listing_for_disconnected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Disconnected,
vec![],
vec![],
None,
);
assert!(registry.list_resources("srv").is_err());
}
#[test]
fn reads_specific_resource() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![],
vec![McpResourceInfo {
uri: "res://data".into(),
name: "Data".into(),
description: Some("Test data".into()),
mime_type: Some("text/plain".into()),
}],
None,
);
let resource = registry
.read_resource("srv", "res://data")
.expect("should find");
assert_eq!(resource.name, "Data");
assert!(registry.read_resource("srv", "res://missing").is_err());
}
#[test]
fn given_connected_server_without_manager_when_calling_tool_then_it_errors() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "greet".into(),
description: None,
input_schema: None,
}],
vec![],
None,
);
let error = registry
.call_tool("srv", "greet", &serde_json::json!({"name": "world"}))
.expect_err("should require a configured manager");
assert!(error.contains("MCP server manager is not configured"));
// Unknown tool should fail
assert!(registry
.call_tool("srv", "missing", &serde_json::json!({}))
.is_err());
}
#[test]
fn given_connected_server_with_manager_when_calling_tool_then_it_returns_live_result() {
let script_path = write_bridge_mcp_server_script();
let root = script_path.parent().expect("script parent");
let log_path = root.join("bridge.log");
let servers = BTreeMap::from([(
"alpha".to_string(),
manager_server_config(&script_path, "alpha", &log_path),
)]);
let manager = Arc::new(Mutex::new(McpServerManager::from_servers(&servers)));
let registry = McpToolRegistry::new();
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "echo".into(),
description: Some("Echo tool for alpha".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
})),
}],
vec![],
Some("bridge test server".into()),
);
registry
.set_manager(Arc::clone(&manager))
.expect("manager should only be set once");
let result = registry
.call_tool("alpha", "echo", &serde_json::json!({"text": "hello"}))
.expect("should return live MCP result");
assert_eq!(
result["structuredContent"]["server"],
serde_json::json!("alpha")
);
assert_eq!(
result["structuredContent"]["echoed"],
serde_json::json!("hello")
);
assert_eq!(
result["content"][0]["text"],
serde_json::json!("alpha:hello")
);
let log = fs::read_to_string(&log_path).expect("read log");
assert_eq!(
log.lines().collect::<Vec<_>>(),
vec!["initialize", "tools/list", "tools/call"]
);
cleanup_script(&script_path);
}
#[test]
fn rejects_tool_call_on_disconnected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![McpToolInfo {
name: "greet".into(),
description: None,
input_schema: None,
}],
vec![],
None,
);
assert!(registry
.call_tool("srv", "greet", &serde_json::json!({}))
.is_err());
}
#[test]
fn sets_auth_and_disconnects() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![],
vec![],
None,
);
registry
.set_auth_status("srv", McpConnectionStatus::Connected)
.expect("should succeed");
let state = registry.get_server("srv").unwrap();
assert_eq!(state.status, McpConnectionStatus::Connected);
let removed = registry.disconnect("srv");
assert!(removed.is_some());
assert!(registry.is_empty());
}
#[test]
fn rejects_operations_on_missing_server() {
let registry = McpToolRegistry::new();
assert!(registry.list_resources("missing").is_err());
assert!(registry.read_resource("missing", "uri").is_err());
assert!(registry.list_tools("missing").is_err());
assert!(registry
.call_tool("missing", "tool", &serde_json::json!({}))
.is_err());
assert!(registry
.set_auth_status("missing", McpConnectionStatus::Connected)
.is_err());
}
#[test]
fn mcp_connection_status_display_all_variants() {
// given
let cases = [
(McpConnectionStatus::Disconnected, "disconnected"),
(McpConnectionStatus::Connecting, "connecting"),
(McpConnectionStatus::Connected, "connected"),
(McpConnectionStatus::AuthRequired, "auth_required"),
(McpConnectionStatus::Error, "error"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("disconnected".to_string(), "disconnected"),
("connecting".to_string(), "connecting"),
("connected".to_string(), "connected"),
("auth_required".to_string(), "auth_required"),
("error".to_string(), "error"),
]
);
}
#[test]
fn list_servers_returns_all_registered() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![],
vec![],
None,
);
registry.register_server(
"beta",
McpConnectionStatus::Connecting,
vec![],
vec![],
None,
);
// when
let servers = registry.list_servers();
// then
assert_eq!(servers.len(), 2);
assert!(servers.iter().any(|server| server.server_name == "alpha"));
assert!(servers.iter().any(|server| server.server_name == "beta"));
}
#[test]
fn list_tools_from_connected_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "inspect".into(),
description: Some("Inspect data".into()),
input_schema: Some(serde_json::json!({"type": "object"})),
}],
vec![],
None,
);
// when
let tools = registry.list_tools("srv").expect("tools should list");
// then
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "inspect");
}
#[test]
fn list_tools_rejects_disconnected_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![],
vec![],
None,
);
// when
let result = registry.list_tools("srv");
// then
let error = result.expect_err("non-connected server should fail");
assert!(error.contains("not connected"));
assert!(error.contains("auth_required"));
}
#[test]
fn list_tools_rejects_missing_server() {
// given
let registry = McpToolRegistry::new();
// when
let result = registry.list_tools("missing");
// then
assert_eq!(
result.expect_err("missing server should fail"),
"server 'missing' not found"
);
}
#[test]
fn get_server_returns_none_for_missing() {
// given
let registry = McpToolRegistry::new();
// when
let server = registry.get_server("missing");
// then
assert!(server.is_none());
}
#[test]
fn call_tool_payload_structure() {
let script_path = write_bridge_mcp_server_script();
let root = script_path.parent().expect("script parent");
let log_path = root.join("payload.log");
let servers = BTreeMap::from([(
"srv".to_string(),
manager_server_config(&script_path, "srv", &log_path),
)]);
let registry = McpToolRegistry::new();
let arguments = serde_json::json!({"text": "world"});
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "echo".into(),
description: Some("Echo tool for srv".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
})),
}],
vec![],
None,
);
registry
.set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
&servers,
))))
.expect("manager should only be set once");
let result = registry
.call_tool("srv", "echo", &arguments)
.expect("tool should return live payload");
assert_eq!(result["structuredContent"]["server"], "srv");
assert_eq!(result["structuredContent"]["echoed"], "world");
assert_eq!(result["content"][0]["text"], "srv:world");
cleanup_script(&script_path);
}
#[test]
fn upsert_overwrites_existing_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server("srv", McpConnectionStatus::Connecting, vec![], vec![], None);
// when
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "inspect".into(),
description: None,
input_schema: None,
}],
vec![],
Some("Inspector".into()),
);
let state = registry.get_server("srv").expect("server should exist");
// then
assert_eq!(state.status, McpConnectionStatus::Connected);
assert_eq!(state.tools.len(), 1);
assert_eq!(state.server_info.as_deref(), Some("Inspector"));
}
#[test]
fn disconnect_missing_returns_none() {
// given
let registry = McpToolRegistry::new();
// when
let removed = registry.disconnect("missing");
// then
assert!(removed.is_none());
}
#[test]
fn len_and_is_empty_transitions() {
// given
let registry = McpToolRegistry::new();
// when
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![],
vec![],
None,
);
registry.register_server("beta", McpConnectionStatus::Connected, vec![], vec![], None);
let after_create = registry.len();
registry.disconnect("alpha");
let after_first_remove = registry.len();
registry.disconnect("beta");
// then
assert_eq!(after_create, 2);
assert_eq!(after_first_remove, 1);
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
}

View File

@@ -9,7 +9,6 @@ use sha2::{Digest, Sha256};
use crate::config::OAuthConfig;
/// Persisted OAuth access token bundle used by the CLI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthTokenSet {
pub access_token: String,
@@ -18,7 +17,6 @@ pub struct OAuthTokenSet {
pub scopes: Vec<String>,
}
/// PKCE verifier/challenge pair generated for an OAuth authorization flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PkceCodePair {
pub verifier: String,
@@ -26,7 +24,6 @@ pub struct PkceCodePair {
pub challenge_method: PkceChallengeMethod,
}
/// Challenge algorithms supported by the local PKCE helpers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkceChallengeMethod {
S256,
@@ -41,7 +38,6 @@ impl PkceChallengeMethod {
}
}
/// Parameters needed to build an authorization URL for browser-based login.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthAuthorizationRequest {
pub authorize_url: String,
@@ -54,7 +50,6 @@ pub struct OAuthAuthorizationRequest {
pub extra_params: BTreeMap<String, String>,
}
/// Request body for exchanging an OAuth authorization code for tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthTokenExchangeRequest {
pub grant_type: &'static str,
@@ -65,7 +60,6 @@ pub struct OAuthTokenExchangeRequest {
pub state: String,
}
/// Request body for refreshing an existing OAuth token set.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthRefreshRequest {
pub grant_type: &'static str,
@@ -74,7 +68,6 @@ pub struct OAuthRefreshRequest {
pub scopes: Vec<String>,
}
/// Parsed query parameters returned to the local OAuth callback endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthCallbackParams {
pub code: Option<String>,
@@ -449,7 +442,7 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(format!("invalid percent byte: {byte}")),
_ => Err(format!("invalid percent-encoding byte: {byte}")),
}
}

View File

@@ -1,546 +0,0 @@
//! Permission enforcement layer that gates tool execution based on the
//! active `PermissionPolicy`.
use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome")]
pub enum EnforcementResult {
/// Tool execution is allowed.
Allowed,
/// Tool execution was denied due to insufficient permissions.
Denied {
tool: String,
active_mode: String,
required_mode: String,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PermissionEnforcer {
policy: PermissionPolicy,
}
impl PermissionEnforcer {
#[must_use]
pub fn new(policy: PermissionPolicy) -> Self {
Self { policy }
}
/// Check whether a tool can be executed under the current permission policy.
/// Auto-denies when prompting is required but no prompter is provided.
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying (the enforcer has no prompter).
if self.policy.active_mode() == PermissionMode::Prompt {
return EnforcementResult::Allowed;
}
let outcome = self.policy.authorize(tool_name, input, None);
match outcome {
PermissionOutcome::Allow => EnforcementResult::Allowed,
PermissionOutcome::Deny { reason } => {
let active_mode = self.policy.active_mode();
let required_mode = self.policy.required_mode_for(tool_name);
EnforcementResult::Denied {
tool: tool_name.to_owned(),
active_mode: active_mode.as_str().to_owned(),
required_mode: required_mode.as_str().to_owned(),
reason,
}
}
}
}
#[must_use]
pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.policy.active_mode()
}
/// Classify a file operation against workspace boundaries.
pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult {
let mode = self.policy.active_mode();
match mode {
PermissionMode::ReadOnly => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
},
PermissionMode::WorkspaceWrite => {
if is_within_workspace(path, workspace_root) {
EnforcementResult::Allowed
} else {
EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: format!(
"path '{}' is outside workspace root '{}'",
path, workspace_root
),
}
}
}
// Allow and DangerFullAccess permit all writes
PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed,
PermissionMode::Prompt => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: "file write requires confirmation in prompt mode".to_owned(),
},
}
}
/// Check if a bash command should be allowed based on current mode.
pub fn check_bash(&self, command: &str) -> EnforcementResult {
let mode = self.policy.active_mode();
match mode {
PermissionMode::ReadOnly => {
if is_read_only_command(command) {
EnforcementResult::Allowed
} else {
EnforcementResult::Denied {
tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!(
"command may modify state; not allowed in '{}' mode",
mode.as_str()
),
}
}
}
PermissionMode::Prompt => EnforcementResult::Denied {
tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: "bash requires confirmation in prompt mode".to_owned(),
},
// WorkspaceWrite, Allow, DangerFullAccess: permit bash
_ => EnforcementResult::Allowed,
}
}
}
/// Simple workspace boundary check via string prefix.
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
let normalized = if path.starts_with('/') {
path.to_owned()
} else {
format!("{workspace_root}/{path}")
};
let root = if workspace_root.ends_with('/') {
workspace_root.to_owned()
} else {
format!("{workspace_root}/")
};
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
}
/// Conservative heuristic: is this bash command read-only?
fn is_read_only_command(command: &str) -> bool {
let first_token = command
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
matches!(
first_token,
"cat"
| "head"
| "tail"
| "less"
| "more"
| "wc"
| "ls"
| "find"
| "grep"
| "rg"
| "awk"
| "sed"
| "echo"
| "printf"
| "which"
| "where"
| "whoami"
| "pwd"
| "env"
| "printenv"
| "date"
| "cal"
| "df"
| "du"
| "free"
| "uptime"
| "uname"
| "file"
| "stat"
| "diff"
| "sort"
| "uniq"
| "tr"
| "cut"
| "paste"
| "tee"
| "xargs"
| "test"
| "true"
| "false"
| "type"
| "readlink"
| "realpath"
| "basename"
| "dirname"
| "sha256sum"
| "md5sum"
| "b3sum"
| "xxd"
| "hexdump"
| "od"
| "strings"
| "tree"
| "jq"
| "yq"
| "python3"
| "python"
| "node"
| "ruby"
| "cargo"
| "rustc"
| "git"
| "gh"
) && !command.contains("-i ")
&& !command.contains("--in-place")
&& !command.contains(" > ")
&& !command.contains(" >> ")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer {
let policy = PermissionPolicy::new(mode);
PermissionEnforcer::new(policy)
}
#[test]
fn allow_mode_permits_everything() {
let enforcer = make_enforcer(PermissionMode::Allow);
assert!(enforcer.is_allowed("bash", ""));
assert!(enforcer.is_allowed("write_file", ""));
assert!(enforcer.is_allowed("edit_file", ""));
assert_eq!(
enforcer.check_file_write("/outside/path", "/workspace"),
EnforcementResult::Allowed
);
assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed);
}
#[test]
fn read_only_denies_writes() {
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("read_file", PermissionMode::ReadOnly)
.with_tool_requirement("grep_search", PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
let enforcer = PermissionEnforcer::new(policy);
assert!(enforcer.is_allowed("read_file", ""));
assert!(enforcer.is_allowed("grep_search", ""));
// write_file requires WorkspaceWrite but we're in ReadOnly
let result = enforcer.check("write_file", "");
assert!(matches!(result, EnforcementResult::Denied { .. }));
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn read_only_allows_read_commands() {
let enforcer = make_enforcer(PermissionMode::ReadOnly);
assert_eq!(
enforcer.check_bash("cat src/main.rs"),
EnforcementResult::Allowed
);
assert_eq!(
enforcer.check_bash("grep -r 'pattern' ."),
EnforcementResult::Allowed
);
assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed);
}
#[test]
fn read_only_denies_write_commands() {
let enforcer = make_enforcer(PermissionMode::ReadOnly);
let result = enforcer.check_bash("rm file.txt");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn workspace_write_allows_within_workspace() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace");
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_write_denies_outside_workspace() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/etc/passwd", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn prompt_mode_denies_without_prompter() {
let enforcer = make_enforcer(PermissionMode::Prompt);
let result = enforcer.check_bash("echo test");
assert!(matches!(result, EnforcementResult::Denied { .. }));
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn workspace_boundary_check() {
assert!(is_within_workspace("/workspace/src/main.rs", "/workspace"));
assert!(is_within_workspace("/workspace", "/workspace"));
assert!(!is_within_workspace("/etc/passwd", "/workspace"));
assert!(!is_within_workspace("/workspacex/hack", "/workspace"));
}
#[test]
fn read_only_command_heuristic() {
assert!(is_read_only_command("cat file.txt"));
assert!(is_read_only_command("grep pattern file"));
assert!(is_read_only_command("git log --oneline"));
assert!(!is_read_only_command("rm file.txt"));
assert!(!is_read_only_command("echo test > file.txt"));
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
}
#[test]
fn active_mode_returns_policy_mode() {
// given
let modes = [
PermissionMode::ReadOnly,
PermissionMode::WorkspaceWrite,
PermissionMode::DangerFullAccess,
PermissionMode::Prompt,
PermissionMode::Allow,
];
// when
let active_modes: Vec<_> = modes
.into_iter()
.map(|mode| make_enforcer(mode).active_mode())
.collect();
// then
assert_eq!(active_modes, modes);
}
#[test]
fn danger_full_access_permits_file_writes_and_bash() {
// given
let enforcer = make_enforcer(PermissionMode::DangerFullAccess);
// when
let file_result = enforcer.check_file_write("/outside/workspace/file.txt", "/workspace");
let bash_result = enforcer.check_bash("rm -rf /tmp/scratch");
// then
assert_eq!(file_result, EnforcementResult::Allowed);
assert_eq!(bash_result, EnforcementResult::Allowed);
}
#[test]
fn check_denied_payload_contains_tool_and_modes() {
// given
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
let enforcer = PermissionEnforcer::new(policy);
// when
let result = enforcer.check("write_file", "{}");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "write_file");
assert_eq!(active_mode, "read-only");
assert_eq!(required_mode, "workspace-write");
assert!(reason.contains("requires workspace-write permission"));
}
other => panic!("expected denied result, got {other:?}"),
}
}
#[test]
fn workspace_write_relative_path_resolved() {
// given
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
// when
let result = enforcer.check_file_write("src/main.rs", "/workspace");
// then
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_root_with_trailing_slash() {
// given
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
// when
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace/");
// then
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_root_equality() {
// given
let root = "/workspace/";
// when
let equal_to_root = is_within_workspace("/workspace", root);
// then
assert!(equal_to_root);
}
#[test]
fn bash_heuristic_full_path_prefix() {
// given
let full_path_command = "/usr/bin/cat Cargo.toml";
let git_path_command = "/usr/local/bin/git status";
// when
let cat_result = is_read_only_command(full_path_command);
let git_result = is_read_only_command(git_path_command);
// then
assert!(cat_result);
assert!(git_result);
}
#[test]
fn bash_heuristic_redirects_block_read_only_commands() {
// given
let overwrite = "cat Cargo.toml > out.txt";
let append = "echo test >> out.txt";
// when
let overwrite_result = is_read_only_command(overwrite);
let append_result = is_read_only_command(append);
// then
assert!(!overwrite_result);
assert!(!append_result);
}
#[test]
fn bash_heuristic_in_place_flag_blocks() {
// given
let interactive_python = "python -i script.py";
let in_place_sed = "sed --in-place 's/a/b/' file.txt";
// when
let interactive_result = is_read_only_command(interactive_python);
let in_place_result = is_read_only_command(in_place_sed);
// then
assert!(!interactive_result);
assert!(!in_place_result);
}
#[test]
fn bash_heuristic_empty_command() {
// given
let empty = "";
let whitespace = " ";
// when
let empty_result = is_read_only_command(empty);
let whitespace_result = is_read_only_command(whitespace);
// then
assert!(!empty_result);
assert!(!whitespace_result);
}
#[test]
fn prompt_mode_check_bash_denied_payload_fields() {
// given
let enforcer = make_enforcer(PermissionMode::Prompt);
// when
let result = enforcer.check_bash("git status");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "bash");
assert_eq!(active_mode, "prompt");
assert_eq!(required_mode, "danger-full-access");
assert_eq!(reason, "bash requires confirmation in prompt mode");
}
other => panic!("expected denied result, got {other:?}"),
}
}
#[test]
fn read_only_check_file_write_denied_payload() {
// given
let enforcer = make_enforcer(PermissionMode::ReadOnly);
// when
let result = enforcer.check_file_write("/workspace/file.txt", "/workspace");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "write_file");
assert_eq!(active_mode, "read-only");
assert_eq!(required_mode, "workspace-write");
assert!(reason.contains("file writes are not allowed"));
}
other => panic!("expected denied result, got {other:?}"),
}
}
}

View File

@@ -1,10 +1,5 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
/// Permission level assigned to a tool invocation or runtime session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
@@ -27,81 +22,34 @@ impl PermissionMode {
}
}
/// Hook-provided override applied before standard permission evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionOverride {
Allow,
Deny,
Ask,
}
/// Additional permission context supplied by hooks or higher-level orchestration.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PermissionContext {
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
}
impl PermissionContext {
#[must_use]
pub fn new(
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
) -> Self {
Self {
override_decision,
override_reason,
}
}
#[must_use]
pub fn override_decision(&self) -> Option<PermissionOverride> {
self.override_decision
}
#[must_use]
pub fn override_reason(&self) -> Option<&str> {
self.override_reason.as_deref()
}
}
/// Full authorization request presented to a permission prompt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
/// User-facing decision returned by a [`PermissionPrompter`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionPromptDecision {
Allow,
Deny { reason: String },
}
/// Prompting interface used when policy requires interactive approval.
pub trait PermissionPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision;
}
/// Final authorization result after evaluating static rules and prompts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionOutcome {
Allow,
Deny { reason: String },
}
/// Evaluates permission mode requirements plus allow/deny/ask rules.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionPolicy {
active_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>,
allow_rules: Vec<PermissionRule>,
deny_rules: Vec<PermissionRule>,
ask_rules: Vec<PermissionRule>,
}
impl PermissionPolicy {
@@ -110,9 +58,6 @@ impl PermissionPolicy {
Self {
active_mode,
tool_requirements: BTreeMap::new(),
allow_rules: Vec::new(),
deny_rules: Vec::new(),
ask_rules: Vec::new(),
}
}
@@ -127,26 +72,6 @@ impl PermissionPolicy {
self
}
#[must_use]
pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
self.allow_rules = config
.allow()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.deny_rules = config
.deny()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.ask_rules = config
.ask()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.active_mode
@@ -165,121 +90,38 @@ impl PermissionPolicy {
&self,
tool_name: &str,
input: &str,
prompter: Option<&mut dyn PermissionPrompter>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn authorize_with_context(
&self,
tool_name: &str,
input: &str,
context: &PermissionContext,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
return PermissionOutcome::Deny {
reason: format!(
"Permission to use {tool_name} has been denied by rule '{}'",
rule.raw
),
};
}
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
match context.override_decision() {
Some(PermissionOverride::Deny) => {
return PermissionOutcome::Deny {
reason: context.override_reason().map_or_else(
|| format!("tool '{tool_name}' denied by hook"),
ToOwned::to_owned,
),
};
}
Some(PermissionOverride::Ask) => {
let reason = context.override_reason().map_or_else(
|| format!("tool '{tool_name}' requires approval due to hook guidance"),
ToOwned::to_owned,
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
}
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
return PermissionOutcome::Allow;
}
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
};
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
));
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
reason,
prompter,
);
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
},
};
}
PermissionOutcome::Deny {
@@ -290,191 +132,14 @@ impl PermissionPolicy {
),
}
}
fn prompt_or_deny(
tool_name: &str,
input: &str,
current_mode: PermissionMode,
required_mode: PermissionMode,
reason: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
reason: reason.clone(),
};
match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: reason.unwrap_or_else(|| {
format!(
"tool '{tool_name}' requires approval to run while mode is {}",
current_mode.as_str()
)
}),
},
}
}
fn find_matching_rule<'a>(
rules: &'a [PermissionRule],
tool_name: &str,
input: &str,
) -> Option<&'a PermissionRule> {
rules.iter().find(|rule| rule.matches(tool_name, input))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionRule {
raw: String,
tool_name: String,
matcher: PermissionRuleMatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PermissionRuleMatcher {
Any,
Exact(String),
Prefix(String),
}
impl PermissionRule {
fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let open = find_first_unescaped(trimmed, '(');
let close = find_last_unescaped(trimmed, ')');
if let (Some(open), Some(close)) = (open, close) {
if close == trimmed.len() - 1 && open < close {
let tool_name = trimmed[..open].trim();
let content = &trimmed[open + 1..close];
if !tool_name.is_empty() {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
matcher,
};
}
}
}
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
fn matches(&self, tool_name: &str, input: &str) -> bool {
if self.tool_name != tool_name {
return false;
}
match &self.matcher {
PermissionRuleMatcher::Any => true,
PermissionRuleMatcher::Exact(expected) => {
extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
}
PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
.is_some_and(|candidate| candidate.starts_with(prefix)),
}
}
}
fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
let unescaped = unescape_rule_content(content.trim());
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
}
}
fn unescape_rule_content(content: &str) -> String {
content
.replace(r"\(", "(")
.replace(r"\)", ")")
.replace(r"\\", r"\")
}
fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
let mut escaped = false;
for (idx, ch) in value.char_indices() {
if ch == '\\' {
escaped = !escaped;
continue;
}
if ch == needle && !escaped {
return Some(idx);
}
escaped = false;
}
None
}
fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
let chars = value.char_indices().collect::<Vec<_>>();
for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
if *ch != needle {
continue;
}
let mut backslashes = 0;
for (_, prev) in chars[..pos].iter().rev() {
if *prev == '\\' {
backslashes += 1;
} else {
break;
}
}
if backslashes % 2 == 0 {
return Some(*idx);
}
}
None
}
fn extract_permission_subject(input: &str) -> Option<String> {
let parsed = serde_json::from_str::<Value>(input).ok();
if let Some(Value::Object(object)) = parsed {
for key in [
"command",
"path",
"file_path",
"filePath",
"notebook_path",
"notebookPath",
"url",
"pattern",
"code",
"message",
] {
if let Some(value) = object.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
}
(!input.trim().is_empty()).then(|| input.to_string())
}
#[cfg(test)]
mod tests {
use super::{
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
use crate::config::RuntimePermissionRuleConfig;
struct RecordingPrompter {
seen: Vec<PermissionRequest>,
@@ -564,120 +229,4 @@ mod tests {
PermissionOutcome::Deny { reason } if reason == "not now"
));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
vec!["bash(git:*)".to_string()],
vec!["bash(rm -rf:*)".to_string()],
Vec::new(),
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
assert_eq!(
policy.authorize("bash", r#"{"command":"git status"}"#, None),
PermissionOutcome::Allow
);
assert!(matches!(
policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
));
}
#[test]
fn ask_rules_force_prompt_even_when_mode_allows() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert!(prompter.seen[0]
.reason
.as_deref()
.is_some_and(|reason| reason.contains("ask rule")));
}
#[test]
fn hook_allow_still_respects_ask_rules() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let context = PermissionContext::new(
Some(PermissionOverride::Allow),
Some("hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context(
"bash",
r#"{"command":"git status"}"#,
&context,
Some(&mut prompter),
);
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
}
#[test]
fn hook_deny_short_circuits_permission_flow() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Deny),
Some("blocked by hook".to_string()),
);
assert_eq!(
policy.authorize_with_context("bash", "{}", &context, None),
PermissionOutcome::Deny {
reason: "blocked by hook".to_string(),
}
);
}
#[test]
fn hook_ask_forces_prompt() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Ask),
Some("hook requested confirmation".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(
prompter.seen[0].reason.as_deref(),
Some("hook requested confirmation")
);
}
}

View File

@@ -1,532 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::config::RuntimePluginConfig;
use crate::mcp_tool_bridge::{McpResourceInfo, McpToolInfo};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub type ToolInfo = McpToolInfo;
pub type ResourceInfo = McpResourceInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServerStatus {
Healthy,
Degraded,
Failed,
}
impl std::fmt::Display for ServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Healthy => write!(f, "healthy"),
Self::Degraded => write!(f, "degraded"),
Self::Failed => write!(f, "failed"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerHealth {
pub server_name: String,
pub status: ServerStatus,
pub capabilities: Vec<String>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum PluginState {
Unconfigured,
Validated,
Starting,
Healthy,
Degraded {
healthy_servers: Vec<String>,
failed_servers: Vec<ServerHealth>,
},
Failed {
reason: String,
},
ShuttingDown,
Stopped,
}
impl PluginState {
#[must_use]
pub fn from_servers(servers: &[ServerHealth]) -> Self {
if servers.is_empty() {
return Self::Failed {
reason: "no servers available".to_string(),
};
}
let healthy_servers = servers
.iter()
.filter(|server| server.status != ServerStatus::Failed)
.map(|server| server.server_name.clone())
.collect::<Vec<_>>();
let failed_servers = servers
.iter()
.filter(|server| server.status == ServerStatus::Failed)
.cloned()
.collect::<Vec<_>>();
let has_degraded_server = servers
.iter()
.any(|server| server.status == ServerStatus::Degraded);
if failed_servers.is_empty() && !has_degraded_server {
Self::Healthy
} else if healthy_servers.is_empty() {
Self::Failed {
reason: format!("all {} servers failed", failed_servers.len()),
}
} else {
Self::Degraded {
healthy_servers,
failed_servers,
}
}
}
}
impl std::fmt::Display for PluginState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unconfigured => write!(f, "unconfigured"),
Self::Validated => write!(f, "validated"),
Self::Starting => write!(f, "starting"),
Self::Healthy => write!(f, "healthy"),
Self::Degraded { .. } => write!(f, "degraded"),
Self::Failed { .. } => write!(f, "failed"),
Self::ShuttingDown => write!(f, "shutting_down"),
Self::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginHealthcheck {
pub plugin_name: String,
pub state: PluginState,
pub servers: Vec<ServerHealth>,
pub last_check: u64,
}
impl PluginHealthcheck {
#[must_use]
pub fn new(plugin_name: impl Into<String>, servers: Vec<ServerHealth>) -> Self {
let state = PluginState::from_servers(&servers);
Self {
plugin_name: plugin_name.into(),
state,
servers,
last_check: now_secs(),
}
}
#[must_use]
pub fn degraded_mode(&self, discovery: &DiscoveryResult) -> Option<DegradedMode> {
match &self.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => Some(DegradedMode {
available_tools: discovery
.tools
.iter()
.map(|tool| tool.name.clone())
.collect(),
unavailable_tools: failed_servers
.iter()
.flat_map(|server| server.capabilities.iter().cloned())
.collect(),
reason: format!(
"{} servers healthy, {} servers failed",
healthy_servers.len(),
failed_servers.len()
),
}),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryResult {
pub tools: Vec<ToolInfo>,
pub resources: Vec<ResourceInfo>,
pub partial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DegradedMode {
pub available_tools: Vec<String>,
pub unavailable_tools: Vec<String>,
pub reason: String,
}
impl DegradedMode {
#[must_use]
pub fn new(
available_tools: Vec<String>,
unavailable_tools: Vec<String>,
reason: impl Into<String>,
) -> Self {
Self {
available_tools,
unavailable_tools,
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginLifecycleEvent {
ConfigValidated,
StartupHealthy,
StartupDegraded,
StartupFailed,
Shutdown,
}
impl std::fmt::Display for PluginLifecycleEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfigValidated => write!(f, "config_validated"),
Self::StartupHealthy => write!(f, "startup_healthy"),
Self::StartupDegraded => write!(f, "startup_degraded"),
Self::StartupFailed => write!(f, "startup_failed"),
Self::Shutdown => write!(f, "shutdown"),
}
}
}
pub trait PluginLifecycle {
fn validate_config(&self, config: &RuntimePluginConfig) -> Result<(), String>;
fn healthcheck(&self) -> PluginHealthcheck;
fn discover(&self) -> DiscoveryResult;
fn shutdown(&mut self) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone)]
struct MockPluginLifecycle {
plugin_name: String,
valid_config: bool,
healthcheck: PluginHealthcheck,
discovery: DiscoveryResult,
shutdown_error: Option<String>,
shutdown_called: bool,
}
impl MockPluginLifecycle {
fn new(
plugin_name: &str,
valid_config: bool,
servers: Vec<ServerHealth>,
discovery: DiscoveryResult,
shutdown_error: Option<String>,
) -> Self {
Self {
plugin_name: plugin_name.to_string(),
valid_config,
healthcheck: PluginHealthcheck::new(plugin_name, servers),
discovery,
shutdown_error,
shutdown_called: false,
}
}
}
impl PluginLifecycle for MockPluginLifecycle {
fn validate_config(&self, _config: &RuntimePluginConfig) -> Result<(), String> {
if self.valid_config {
Ok(())
} else {
Err(format!(
"plugin `{}` failed configuration validation",
self.plugin_name
))
}
}
fn healthcheck(&self) -> PluginHealthcheck {
if self.shutdown_called {
PluginHealthcheck {
plugin_name: self.plugin_name.clone(),
state: PluginState::Stopped,
servers: self.healthcheck.servers.clone(),
last_check: now_secs(),
}
} else {
self.healthcheck.clone()
}
}
fn discover(&self) -> DiscoveryResult {
self.discovery.clone()
}
fn shutdown(&mut self) -> Result<(), String> {
if let Some(error) = &self.shutdown_error {
return Err(error.clone());
}
self.shutdown_called = true;
Ok(())
}
}
fn healthy_server(name: &str, capabilities: &[&str]) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Healthy,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: None,
}
}
fn failed_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Failed,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: Some(error.to_string()),
}
}
fn degraded_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Degraded,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: Some(error.to_string()),
}
}
fn tool(name: &str) -> ToolInfo {
ToolInfo {
name: name.to_string(),
description: Some(format!("{name} tool")),
input_schema: None,
}
}
fn resource(name: &str, uri: &str) -> ResourceInfo {
ResourceInfo {
uri: uri.to_string(),
name: name.to_string(),
description: Some(format!("{name} resource")),
mime_type: Some("application/json".to_string()),
}
}
#[test]
fn full_lifecycle_happy_path() {
// given
let mut lifecycle = MockPluginLifecycle::new(
"healthy-plugin",
true,
vec![
healthy_server("alpha", &["search", "read"]),
healthy_server("beta", &["write"]),
],
DiscoveryResult {
tools: vec![tool("search"), tool("read"), tool("write")],
resources: vec![resource("docs", "file:///docs")],
partial: false,
},
None,
);
let config = RuntimePluginConfig::default();
// when
let validation = lifecycle.validate_config(&config);
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
let shutdown = lifecycle.shutdown();
let post_shutdown = lifecycle.healthcheck();
// then
assert_eq!(validation, Ok(()));
assert_eq!(healthcheck.state, PluginState::Healthy);
assert_eq!(healthcheck.plugin_name, "healthy-plugin");
assert_eq!(discovery.tools.len(), 3);
assert_eq!(discovery.resources.len(), 1);
assert!(!discovery.partial);
assert_eq!(shutdown, Ok(()));
assert_eq!(post_shutdown.state, PluginState::Stopped);
}
#[test]
fn degraded_startup_when_one_of_three_servers_fails() {
// given
let lifecycle = MockPluginLifecycle::new(
"degraded-plugin",
true,
vec![
healthy_server("alpha", &["search"]),
failed_server("beta", &["write"], "connection refused"),
healthy_server("gamma", &["read"]),
],
DiscoveryResult {
tools: vec![tool("search"), tool("read")],
resources: vec![resource("alpha-docs", "file:///alpha")],
partial: true,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
let degraded_mode = healthcheck
.degraded_mode(&discovery)
.expect("degraded startup should expose degraded mode");
// then
match healthcheck.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => {
assert_eq!(
healthy_servers,
vec!["alpha".to_string(), "gamma".to_string()]
);
assert_eq!(failed_servers.len(), 1);
assert_eq!(failed_servers[0].server_name, "beta");
assert_eq!(
failed_servers[0].last_error.as_deref(),
Some("connection refused")
);
}
other => panic!("expected degraded state, got {other:?}"),
}
assert!(discovery.partial);
assert_eq!(
degraded_mode.available_tools,
vec!["search".to_string(), "read".to_string()]
);
assert_eq!(degraded_mode.unavailable_tools, vec!["write".to_string()]);
assert_eq!(degraded_mode.reason, "2 servers healthy, 1 servers failed");
}
#[test]
fn degraded_server_status_keeps_server_usable() {
// given
let lifecycle = MockPluginLifecycle::new(
"soft-degraded-plugin",
true,
vec![
healthy_server("alpha", &["search"]),
degraded_server("beta", &["write"], "high latency"),
],
DiscoveryResult {
tools: vec![tool("search"), tool("write")],
resources: Vec::new(),
partial: true,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
// then
match healthcheck.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => {
assert_eq!(
healthy_servers,
vec!["alpha".to_string(), "beta".to_string()]
);
assert!(failed_servers.is_empty());
}
other => panic!("expected degraded state, got {other:?}"),
}
}
#[test]
fn complete_failure_when_all_servers_fail() {
// given
let lifecycle = MockPluginLifecycle::new(
"failed-plugin",
true,
vec![
failed_server("alpha", &["search"], "timeout"),
failed_server("beta", &["read"], "handshake failed"),
],
DiscoveryResult {
tools: Vec::new(),
resources: Vec::new(),
partial: false,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
// then
match &healthcheck.state {
PluginState::Failed { reason } => {
assert_eq!(reason, "all 2 servers failed");
}
other => panic!("expected failed state, got {other:?}"),
}
assert!(!discovery.partial);
assert!(discovery.tools.is_empty());
assert!(discovery.resources.is_empty());
assert!(healthcheck.degraded_mode(&discovery).is_none());
}
#[test]
fn graceful_shutdown() {
// given
let mut lifecycle = MockPluginLifecycle::new(
"shutdown-plugin",
true,
vec![healthy_server("alpha", &["search"])],
DiscoveryResult {
tools: vec![tool("search")],
resources: Vec::new(),
partial: false,
},
None,
);
// when
let shutdown = lifecycle.shutdown();
let post_shutdown = lifecycle.healthcheck();
// then
assert_eq!(shutdown, Ok(()));
assert_eq!(PluginLifecycleEvent::Shutdown.to_string(), "shutdown");
assert_eq!(post_shutdown.state, PluginState::Stopped);
}
}

View File

@@ -1,581 +0,0 @@
use std::time::Duration;
pub type GreenLevel = u8;
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyRule {
pub name: String,
pub condition: PolicyCondition,
pub action: PolicyAction,
pub priority: u32,
}
impl PolicyRule {
#[must_use]
pub fn new(
name: impl Into<String>,
condition: PolicyCondition,
action: PolicyAction,
priority: u32,
) -> Self {
Self {
name: name.into(),
condition,
action,
priority,
}
}
#[must_use]
pub fn matches(&self, context: &LaneContext) -> bool {
self.condition.matches(context)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyCondition {
And(Vec<PolicyCondition>),
Or(Vec<PolicyCondition>),
GreenAt { level: GreenLevel },
StaleBranch,
StartupBlocked,
LaneCompleted,
LaneReconciled,
ReviewPassed,
ScopedDiff,
TimedOut { duration: Duration },
}
impl PolicyCondition {
#[must_use]
pub fn matches(&self, context: &LaneContext) -> bool {
match self {
Self::And(conditions) => conditions
.iter()
.all(|condition| condition.matches(context)),
Self::Or(conditions) => conditions
.iter()
.any(|condition| condition.matches(context)),
Self::GreenAt { level } => context.green_level >= *level,
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
Self::LaneCompleted => context.completed,
Self::LaneReconciled => context.reconciled,
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
Self::TimedOut { duration } => context.branch_freshness >= *duration,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyAction {
MergeToDev,
MergeForward,
RecoverOnce,
Escalate { reason: String },
CloseoutLane,
CleanupSession,
Reconcile { reason: ReconcileReason },
Notify { channel: String },
Block { reason: String },
Chain(Vec<PolicyAction>),
}
/// Why a lane was reconciled without further action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconcileReason {
/// Branch already merged into main — no PR needed.
AlreadyMerged,
/// Work superseded by another lane or direct commit.
Superseded,
/// PR would be empty — all changes already landed.
EmptyDiff,
/// Lane manually closed by operator.
ManualClose,
}
impl PolicyAction {
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
match self {
Self::Chain(chained) => {
for action in chained {
action.flatten_into(actions);
}
}
_ => actions.push(self.clone()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaneBlocker {
None,
Startup,
External,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewStatus {
Pending,
Approved,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffScope {
Full,
Scoped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaneContext {
pub lane_id: String,
pub green_level: GreenLevel,
pub branch_freshness: Duration,
pub blocker: LaneBlocker,
pub review_status: ReviewStatus,
pub diff_scope: DiffScope,
pub completed: bool,
pub reconciled: bool,
}
impl LaneContext {
#[must_use]
pub fn new(
lane_id: impl Into<String>,
green_level: GreenLevel,
branch_freshness: Duration,
blocker: LaneBlocker,
review_status: ReviewStatus,
diff_scope: DiffScope,
completed: bool,
) -> Self {
Self {
lane_id: lane_id.into(),
green_level,
branch_freshness,
blocker,
review_status,
diff_scope,
completed,
reconciled: false,
}
}
/// Create a lane context that is already reconciled (no further action needed).
#[must_use]
pub fn reconciled(lane_id: impl Into<String>) -> Self {
Self {
lane_id: lane_id.into(),
green_level: 0,
branch_freshness: Duration::from_secs(0),
blocker: LaneBlocker::None,
review_status: ReviewStatus::Pending,
diff_scope: DiffScope::Full,
completed: true,
reconciled: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyEngine {
rules: Vec<PolicyRule>,
}
impl PolicyEngine {
#[must_use]
pub fn new(mut rules: Vec<PolicyRule>) -> Self {
rules.sort_by_key(|rule| rule.priority);
Self { rules }
}
#[must_use]
pub fn rules(&self) -> &[PolicyRule] {
&self.rules
}
#[must_use]
pub fn evaluate(&self, context: &LaneContext) -> Vec<PolicyAction> {
evaluate(self, context)
}
}
#[must_use]
pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
let mut actions = Vec::new();
for rule in &engine.rules {
if rule.matches(context) {
rule.action.flatten_into(&mut actions);
}
}
actions
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
PolicyRule, ReconcileReason, ReviewStatus, STALE_BRANCH_THRESHOLD,
};
fn default_context() -> LaneContext {
LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
)
}
#[test]
fn merge_to_dev_rule_fires_for_green_scoped_reviewed_lane() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-to-dev",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 2 },
PolicyCondition::ScopedDiff,
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeToDev,
20,
)]);
let context = LaneContext::new(
"lane-7",
3,
Duration::from_secs(5),
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
}
#[test]
fn stale_branch_rule_fires_at_threshold() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
let context = LaneContext::new(
"lane-7",
1,
STALE_BRANCH_THRESHOLD,
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(actions, vec![PolicyAction::MergeForward]);
}
#[test]
fn startup_blocked_rule_recovers_then_escalates() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"startup-recovery",
PolicyCondition::StartupBlocked,
PolicyAction::Chain(vec![
PolicyAction::RecoverOnce,
PolicyAction::Escalate {
reason: "startup remained blocked".to_string(),
},
]),
15,
)]);
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::Startup,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![
PolicyAction::RecoverOnce,
PolicyAction::Escalate {
reason: "startup remained blocked".to_string(),
},
]
);
}
#[test]
fn completed_lane_rule_closes_out_and_cleans_up() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"lane-closeout",
PolicyCondition::LaneCompleted,
PolicyAction::Chain(vec![
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
]),
30,
)]);
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
true,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![PolicyAction::CloseoutLane, PolicyAction::CleanupSession]
);
}
#[test]
fn matching_rules_are_returned_in_priority_order_with_stable_ties() {
// given
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"late-cleanup",
PolicyCondition::And(vec![]),
PolicyAction::CleanupSession,
30,
),
PolicyRule::new(
"first-notify",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "ops".to_string(),
},
10,
),
PolicyRule::new(
"second-notify",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "review".to_string(),
},
10,
),
PolicyRule::new(
"merge",
PolicyCondition::And(vec![]),
PolicyAction::MergeToDev,
20,
),
]);
let context = default_context();
// when
let actions = evaluate(&engine, &context);
// then
assert_eq!(
actions,
vec![
PolicyAction::Notify {
channel: "ops".to_string(),
},
PolicyAction::Notify {
channel: "review".to_string(),
},
PolicyAction::MergeToDev,
PolicyAction::CleanupSession,
]
);
}
#[test]
fn combinators_handle_empty_cases_and_nested_chains() {
// given
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"empty-and",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "orchestrator".to_string(),
},
5,
),
PolicyRule::new(
"empty-or",
PolicyCondition::Or(vec![]),
PolicyAction::Block {
reason: "should not fire".to_string(),
},
10,
),
PolicyRule::new(
"nested",
PolicyCondition::Or(vec![
PolicyCondition::StartupBlocked,
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 2 },
PolicyCondition::TimedOut {
duration: Duration::from_secs(5),
},
]),
]),
PolicyAction::Chain(vec![
PolicyAction::Notify {
channel: "alerts".to_string(),
},
PolicyAction::Chain(vec![
PolicyAction::MergeForward,
PolicyAction::CleanupSession,
]),
]),
15,
),
]);
let context = LaneContext::new(
"lane-7",
2,
Duration::from_secs(10),
LaneBlocker::External,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![
PolicyAction::Notify {
channel: "orchestrator".to_string(),
},
PolicyAction::Notify {
channel: "alerts".to_string(),
},
PolicyAction::MergeForward,
PolicyAction::CleanupSession,
]
);
}
#[test]
fn reconciled_lane_emits_reconcile_and_cleanup() {
// given — a lane where branch is already merged, no PR needed, session stale
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"reconcile-closeout",
PolicyCondition::LaneReconciled,
PolicyAction::Chain(vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
]),
5,
),
// This rule should NOT fire — reconciled lanes are completed but we want
// the more specific reconcile rule to handle them
PolicyRule::new(
"generic-closeout",
PolicyCondition::And(vec![
PolicyCondition::LaneCompleted,
// Only fire if NOT reconciled
PolicyCondition::And(vec![]),
]),
PolicyAction::CloseoutLane,
30,
),
]);
let context = LaneContext::reconciled("lane-9411");
// when
let actions = engine.evaluate(&context);
// then — reconcile rule fires first (priority 5), then generic closeout also fires
// because reconciled context has completed=true
assert_eq!(
actions,
vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
PolicyAction::CloseoutLane,
]
);
}
#[test]
fn reconciled_context_has_correct_defaults() {
let ctx = LaneContext::reconciled("test-lane");
assert_eq!(ctx.lane_id, "test-lane");
assert!(ctx.completed);
assert!(ctx.reconciled);
assert_eq!(ctx.blocker, LaneBlocker::None);
assert_eq!(ctx.green_level, 0);
}
#[test]
fn non_reconciled_lane_does_not_trigger_reconcile_rule() {
let engine = PolicyEngine::new(vec![PolicyRule::new(
"reconcile-closeout",
PolicyCondition::LaneReconciled,
PolicyAction::Reconcile {
reason: ReconcileReason::EmptyDiff,
},
5,
)]);
// Normal completed lane — not reconciled
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
true,
);
let actions = engine.evaluate(&context);
assert!(actions.is_empty());
}
#[test]
fn reconcile_reason_variants_are_distinct() {
assert_ne!(ReconcileReason::AlreadyMerged, ReconcileReason::Superseded);
assert_ne!(ReconcileReason::EmptyDiff, ReconcileReason::ManualClose);
}
}

View File

@@ -5,7 +5,6 @@ use std::process::Command;
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
/// Errors raised while assembling the final system prompt.
#[derive(Debug)]
pub enum PromptBuildError {
Io(std::io::Error),
@@ -35,21 +34,17 @@ impl From<ConfigError> for PromptBuildError {
}
}
/// Marker separating static prompt scaffolding from dynamic runtime context.
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
/// Human-readable default frontier model name embedded into generated prompts.
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
/// Contents of an instruction file included in prompt construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextFile {
pub path: PathBuf,
pub content: String,
}
/// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext {
pub cwd: PathBuf,
@@ -86,7 +81,6 @@ impl ProjectContext {
}
}
/// Builder for the runtime system prompt and dynamic environment sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptBuilder {
output_style_name: Option<String>,
@@ -190,7 +184,6 @@ impl SystemPromptBuilder {
}
}
/// Formats each item as an indented bullet for prompt sections.
#[must_use]
pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
@@ -408,7 +401,6 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
/// Loads config and project context, then renders the system prompt text.
pub fn load_system_prompt(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
@@ -521,13 +513,6 @@ mod tests {
crate::test_env_lock()
}
fn ensure_valid_cwd() {
if std::env::current_dir().is_err() {
std::env::set_current_dir(env!("CARGO_MANIFEST_DIR"))
.expect("test cwd should be recoverable");
}
}
#[test]
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
@@ -616,7 +601,6 @@ mod tests {
#[test]
fn discover_with_git_includes_status_snapshot() {
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -642,7 +626,6 @@ mod tests {
#[test]
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -695,7 +678,6 @@ mod tests {
.expect("write settings");
let _guard = env_lock();
ensure_valid_cwd();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();

View File

@@ -1,630 +0,0 @@
//! Recovery recipes for common failure scenarios.
//!
//! Encodes known automatic recoveries for the six failure scenarios
//! listed in ROADMAP item 8, and enforces one automatic recovery
//! attempt before escalation. Each attempt is emitted as a structured
//! recovery event.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::worker_boot::WorkerFailureKind;
/// The six failure scenarios that have known recovery recipes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureScenario {
TrustPromptUnresolved,
PromptMisdelivery,
StaleBranch,
CompileRedCrossCrate,
McpHandshakeFailure,
PartialPluginStartup,
ProviderFailure,
}
impl FailureScenario {
/// Returns all known failure scenarios.
#[must_use]
pub fn all() -> &'static [FailureScenario] {
&[
Self::TrustPromptUnresolved,
Self::PromptMisdelivery,
Self::StaleBranch,
Self::CompileRedCrossCrate,
Self::McpHandshakeFailure,
Self::PartialPluginStartup,
Self::ProviderFailure,
]
}
/// Map a `WorkerFailureKind` to the corresponding `FailureScenario`.
/// This is the bridge that lets recovery policy consume worker boot events.
#[must_use]
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
match kind {
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
WorkerFailureKind::Provider => Self::ProviderFailure,
}
}
}
impl std::fmt::Display for FailureScenario {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TrustPromptUnresolved => write!(f, "trust_prompt_unresolved"),
Self::PromptMisdelivery => write!(f, "prompt_misdelivery"),
Self::StaleBranch => write!(f, "stale_branch"),
Self::CompileRedCrossCrate => write!(f, "compile_red_cross_crate"),
Self::McpHandshakeFailure => write!(f, "mcp_handshake_failure"),
Self::PartialPluginStartup => write!(f, "partial_plugin_startup"),
Self::ProviderFailure => write!(f, "provider_failure"),
}
}
}
/// Individual step that can be executed as part of a recovery recipe.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryStep {
AcceptTrustPrompt,
RedirectPromptToAgent,
RebaseBranch,
CleanBuild,
RetryMcpHandshake { timeout: u64 },
RestartPlugin { name: String },
RestartWorker,
EscalateToHuman { reason: String },
}
/// Policy governing what happens when automatic recovery is exhausted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationPolicy {
AlertHuman,
LogAndContinue,
Abort,
}
/// A recovery recipe encodes the sequence of steps to attempt for a
/// given failure scenario, along with the maximum number of automatic
/// attempts and the escalation policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecoveryRecipe {
pub scenario: FailureScenario,
pub steps: Vec<RecoveryStep>,
pub max_attempts: u32,
pub escalation_policy: EscalationPolicy,
}
/// Outcome of a recovery attempt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryResult {
Recovered {
steps_taken: u32,
},
PartialRecovery {
recovered: Vec<RecoveryStep>,
remaining: Vec<RecoveryStep>,
},
EscalationRequired {
reason: String,
},
}
/// Structured event emitted during recovery.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryEvent {
RecoveryAttempted {
scenario: FailureScenario,
recipe: RecoveryRecipe,
result: RecoveryResult,
},
RecoverySucceeded,
RecoveryFailed,
Escalated,
}
/// Minimal context for tracking recovery state and emitting events.
///
/// Holds per-scenario attempt counts, a structured event log, and an
/// optional simulation knob for controlling step outcomes during tests.
#[derive(Debug, Clone, Default)]
pub struct RecoveryContext {
attempts: HashMap<FailureScenario, u32>,
events: Vec<RecoveryEvent>,
/// Optional step index at which simulated execution fails.
/// `None` means all steps succeed.
fail_at_step: Option<usize>,
}
impl RecoveryContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Configure a step index at which simulated execution will fail.
#[must_use]
pub fn with_fail_at_step(mut self, index: usize) -> Self {
self.fail_at_step = Some(index);
self
}
/// Returns the structured event log populated during recovery.
#[must_use]
pub fn events(&self) -> &[RecoveryEvent] {
&self.events
}
/// Returns the number of recovery attempts made for a scenario.
#[must_use]
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
self.attempts.get(scenario).copied().unwrap_or(0)
}
}
/// Returns the known recovery recipe for the given failure scenario.
#[must_use]
pub fn recipe_for(scenario: &FailureScenario) -> RecoveryRecipe {
match scenario {
FailureScenario::TrustPromptUnresolved => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::AcceptTrustPrompt],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::PromptMisdelivery => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RedirectPromptToAgent],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::StaleBranch => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RebaseBranch, RecoveryStep::CleanBuild],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::CompileRedCrossCrate => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::CleanBuild],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::McpHandshakeFailure => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RetryMcpHandshake { timeout: 5000 }],
max_attempts: 1,
escalation_policy: EscalationPolicy::Abort,
},
FailureScenario::PartialPluginStartup => RecoveryRecipe {
scenario: *scenario,
steps: vec![
RecoveryStep::RestartPlugin {
name: "stalled".to_string(),
},
RecoveryStep::RetryMcpHandshake { timeout: 3000 },
],
max_attempts: 1,
escalation_policy: EscalationPolicy::LogAndContinue,
},
FailureScenario::ProviderFailure => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RestartWorker],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
}
}
/// Attempts automatic recovery for the given failure scenario.
///
/// Looks up the recipe, enforces the one-attempt-before-escalation
/// policy, simulates step execution (controlled by the context), and
/// emits structured [`RecoveryEvent`]s for every attempt.
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
let recipe = recipe_for(scenario);
let attempt_count = ctx.attempts.entry(*scenario).or_insert(0);
// Enforce one automatic recovery attempt before escalation.
if *attempt_count >= recipe.max_attempts {
let result = RecoveryResult::EscalationRequired {
reason: format!(
"max recovery attempts ({}) exceeded for {}",
recipe.max_attempts, scenario
),
};
ctx.events.push(RecoveryEvent::RecoveryAttempted {
scenario: *scenario,
recipe,
result: result.clone(),
});
ctx.events.push(RecoveryEvent::Escalated);
return result;
}
*attempt_count += 1;
// Execute steps, honoring the optional fail_at_step simulation.
let fail_index = ctx.fail_at_step;
let mut executed = Vec::new();
let mut failed = false;
for (i, step) in recipe.steps.iter().enumerate() {
if fail_index == Some(i) {
failed = true;
break;
}
executed.push(step.clone());
}
let result = if failed {
let remaining: Vec<RecoveryStep> = recipe.steps[executed.len()..].to_vec();
if executed.is_empty() {
RecoveryResult::EscalationRequired {
reason: format!("recovery failed at first step for {}", scenario),
}
} else {
RecoveryResult::PartialRecovery {
recovered: executed,
remaining,
}
}
} else {
RecoveryResult::Recovered {
steps_taken: recipe.steps.len() as u32,
}
};
// Emit the attempt as structured event data.
ctx.events.push(RecoveryEvent::RecoveryAttempted {
scenario: *scenario,
recipe,
result: result.clone(),
});
match &result {
RecoveryResult::Recovered { .. } => {
ctx.events.push(RecoveryEvent::RecoverySucceeded);
}
RecoveryResult::PartialRecovery { .. } => {
ctx.events.push(RecoveryEvent::RecoveryFailed);
}
RecoveryResult::EscalationRequired { .. } => {
ctx.events.push(RecoveryEvent::Escalated);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_scenario_has_a_matching_recipe() {
// given
let scenarios = FailureScenario::all();
// when / then
for scenario in scenarios {
let recipe = recipe_for(scenario);
assert_eq!(
recipe.scenario, *scenario,
"recipe scenario should match requested scenario"
);
assert!(
!recipe.steps.is_empty(),
"recipe for {} should have at least one step",
scenario
);
assert!(
recipe.max_attempts >= 1,
"recipe for {} should allow at least one attempt",
scenario
);
}
}
#[test]
fn successful_recovery_returns_recovered_and_emits_events() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::TrustPromptUnresolved;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 1 });
assert_eq!(ctx.events().len(), 2);
assert!(matches!(
&ctx.events()[0],
RecoveryEvent::RecoveryAttempted {
scenario: s,
result: r,
..
} if *s == FailureScenario::TrustPromptUnresolved
&& matches!(r, RecoveryResult::Recovered { steps_taken: 1 })
));
assert_eq!(ctx.events()[1], RecoveryEvent::RecoverySucceeded);
}
#[test]
fn escalation_after_max_attempts_exceeded() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::PromptMisdelivery;
// when — first attempt succeeds
let first = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(first, RecoveryResult::Recovered { .. }));
// when — second attempt should escalate
let second = attempt_recovery(&scenario, &mut ctx);
// then
assert!(
matches!(
&second,
RecoveryResult::EscalationRequired { reason }
if reason.contains("max recovery attempts")
),
"second attempt should require escalation, got: {second:?}"
);
assert_eq!(ctx.attempt_count(&scenario), 1);
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
#[test]
fn partial_recovery_when_step_fails_midway() {
// given — PartialPluginStartup has two steps; fail at step index 1
let mut ctx = RecoveryContext::new().with_fail_at_step(1);
let scenario = FailureScenario::PartialPluginStartup;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
match &result {
RecoveryResult::PartialRecovery {
recovered,
remaining,
} => {
assert_eq!(recovered.len(), 1, "one step should have succeeded");
assert_eq!(remaining.len(), 1, "one step should remain");
assert!(matches!(recovered[0], RecoveryStep::RestartPlugin { .. }));
assert!(matches!(
remaining[0],
RecoveryStep::RetryMcpHandshake { .. }
));
}
other => panic!("expected PartialRecovery, got {other:?}"),
}
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::RecoveryFailed)));
}
#[test]
fn first_step_failure_escalates_immediately() {
// given — fail at step index 0
let mut ctx = RecoveryContext::new().with_fail_at_step(0);
let scenario = FailureScenario::CompileRedCrossCrate;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert!(
matches!(
&result,
RecoveryResult::EscalationRequired { reason }
if reason.contains("failed at first step")
),
"zero-step failure should escalate, got: {result:?}"
);
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
#[test]
fn emitted_events_include_structured_attempt_data() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::McpHandshakeFailure;
// when
let _ = attempt_recovery(&scenario, &mut ctx);
// then — verify the RecoveryAttempted event carries full context
let attempted = ctx
.events()
.iter()
.find(|e| matches!(e, RecoveryEvent::RecoveryAttempted { .. }))
.expect("should have emitted RecoveryAttempted event");
match attempted {
RecoveryEvent::RecoveryAttempted {
scenario: s,
recipe,
result,
} => {
assert_eq!(*s, scenario);
assert_eq!(recipe.scenario, scenario);
assert!(!recipe.steps.is_empty());
assert!(matches!(result, RecoveryResult::Recovered { .. }));
}
_ => unreachable!(),
}
// Verify the event is serializable as structured JSON
let json = serde_json::to_string(&ctx.events()[0])
.expect("recovery event should be serializable to JSON");
assert!(
json.contains("mcp_handshake_failure"),
"serialized event should contain scenario name"
);
}
#[test]
fn recovery_context_tracks_attempts_per_scenario() {
// given
let mut ctx = RecoveryContext::new();
// when
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 0);
attempt_recovery(&FailureScenario::StaleBranch, &mut ctx);
// then
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 1);
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
}
#[test]
fn stale_branch_recipe_has_rebase_then_clean_build() {
// given
let recipe = recipe_for(&FailureScenario::StaleBranch);
// then
assert_eq!(recipe.steps.len(), 2);
assert_eq!(recipe.steps[0], RecoveryStep::RebaseBranch);
assert_eq!(recipe.steps[1], RecoveryStep::CleanBuild);
}
#[test]
fn partial_plugin_startup_recipe_has_restart_then_handshake() {
// given
let recipe = recipe_for(&FailureScenario::PartialPluginStartup);
// then
assert_eq!(recipe.steps.len(), 2);
assert!(matches!(
recipe.steps[0],
RecoveryStep::RestartPlugin { .. }
));
assert!(matches!(
recipe.steps[1],
RecoveryStep::RetryMcpHandshake { timeout: 3000 }
));
assert_eq!(recipe.escalation_policy, EscalationPolicy::LogAndContinue);
}
#[test]
fn failure_scenario_display_all_variants() {
// given
let cases = [
(
FailureScenario::TrustPromptUnresolved,
"trust_prompt_unresolved",
),
(FailureScenario::PromptMisdelivery, "prompt_misdelivery"),
(FailureScenario::StaleBranch, "stale_branch"),
(
FailureScenario::CompileRedCrossCrate,
"compile_red_cross_crate",
),
(
FailureScenario::McpHandshakeFailure,
"mcp_handshake_failure",
),
(
FailureScenario::PartialPluginStartup,
"partial_plugin_startup",
),
];
// when / then
for (scenario, expected) in &cases {
assert_eq!(scenario.to_string(), *expected);
}
}
#[test]
fn multi_step_success_reports_correct_steps_taken() {
// given — StaleBranch has 2 steps, no simulated failure
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::StaleBranch;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 2 });
}
#[test]
fn mcp_handshake_recipe_uses_abort_escalation_policy() {
// given
let recipe = recipe_for(&FailureScenario::McpHandshakeFailure);
// then
assert_eq!(recipe.escalation_policy, EscalationPolicy::Abort);
assert_eq!(recipe.max_attempts, 1);
}
#[test]
fn worker_failure_kind_maps_to_failure_scenario() {
// given / when / then — verify the bridge is correct
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::TrustGate),
FailureScenario::TrustPromptUnresolved,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::PromptDelivery),
FailureScenario::PromptMisdelivery,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::Protocol),
FailureScenario::McpHandshakeFailure,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::Provider),
FailureScenario::ProviderFailure,
);
}
#[test]
fn provider_failure_recipe_uses_restart_worker_step() {
// given
let recipe = recipe_for(&FailureScenario::ProviderFailure);
// then
assert_eq!(recipe.scenario, FailureScenario::ProviderFailure);
assert!(recipe.steps.contains(&RecoveryStep::RestartWorker));
assert_eq!(recipe.escalation_policy, EscalationPolicy::AlertHuman);
assert_eq!(recipe.max_attempts, 1);
}
#[test]
fn provider_failure_recovery_attempt_succeeds_then_escalates() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::ProviderFailure;
// when — first attempt
let first = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(first, RecoveryResult::Recovered { .. }));
// when — second attempt should escalate (max_attempts=1)
let second = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(second, RecoveryResult::EscalationRequired { .. }));
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
}

View File

@@ -161,7 +161,7 @@ pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStat
#[must_use]
pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
let container = detect_container_environment();
let namespace_supported = cfg!(target_os = "linux") && unshare_user_namespace_works();
let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
let network_supported = namespace_supported;
let filesystem_active =
request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
@@ -282,27 +282,6 @@ fn command_exists(command: &str) -> bool {
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
}
/// Check whether `unshare --user` actually works on this system.
/// On some CI environments (e.g. GitHub Actions), the binary exists but
/// user namespaces are restricted, causing silent failures.
fn unshare_user_namespace_works() -> bool {
use std::sync::OnceLock;
static RESULT: OnceLock<bool> = OnceLock::new();
*RESULT.get_or_init(|| {
if !command_exists("unshare") {
return false;
}
std::process::Command::new("unshare")
.args(["--user", "--map-root-user", "true"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::{

View File

@@ -1,20 +1,11 @@
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use std::fs;
use std::path::Path;
use crate::json::{JsonError, JsonValue};
use crate::usage::TokenUsage;
const SESSION_VERSION: u32 = 1;
const ROTATE_AFTER_BYTES: u64 = 256 * 1024;
const MAX_ROTATED_FILES: usize = 3;
static SESSION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Speaker role associated with a persisted conversation message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRole {
System,
@@ -23,7 +14,6 @@ pub enum MessageRole {
Tool,
}
/// Structured message content stored inside a [`Session`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentBlock {
Text {
@@ -42,7 +32,6 @@ pub enum ContentBlock {
},
}
/// One conversation message with optional token-usage metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationMessage {
pub role: MessageRole,
@@ -50,54 +39,12 @@ pub struct ConversationMessage {
pub usage: Option<TokenUsage>,
}
/// Metadata describing the latest compaction that summarized a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCompaction {
pub count: u32,
pub removed_message_count: usize,
pub summary: String,
}
/// Provenance recorded when a session is forked from another session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionFork {
pub parent_session_id: String,
pub branch_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionPersistence {
path: PathBuf,
}
/// Persisted conversational state for the runtime and CLI session manager.
#[derive(Debug, Clone)]
pub struct Session {
pub version: u32,
pub session_id: String,
pub created_at_ms: u64,
pub updated_at_ms: u64,
pub messages: Vec<ConversationMessage>,
pub compaction: Option<SessionCompaction>,
pub fork: Option<SessionFork>,
persistence: Option<SessionPersistence>,
}
impl PartialEq for Session {
fn eq(&self, other: &Self) -> bool {
self.version == other.version
&& self.session_id == other.session_id
&& self.created_at_ms == other.created_at_ms
&& self.updated_at_ms == other.updated_at_ms
&& self.messages == other.messages
&& self.compaction == other.compaction
&& self.fork == other.fork
}
}
impl Eq for Session {}
/// Errors raised while loading, parsing, or saving sessions.
#[derive(Debug)]
pub enum SessionError {
Io(std::io::Error),
@@ -132,121 +79,29 @@ impl From<JsonError> for SessionError {
impl Session {
#[must_use]
pub fn new() -> Self {
let now = current_time_millis();
Self {
version: SESSION_VERSION,
session_id: generate_session_id(),
created_at_ms: now,
updated_at_ms: now,
version: 1,
messages: Vec::new(),
compaction: None,
fork: None,
persistence: None,
}
}
#[must_use]
pub fn with_persistence_path(mut self, path: impl Into<PathBuf>) -> Self {
self.persistence = Some(SessionPersistence { path: path.into() });
self
}
#[must_use]
pub fn persistence_path(&self) -> Option<&Path> {
self.persistence.as_ref().map(|value| value.path.as_path())
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
let path = path.as_ref();
let snapshot = self.render_jsonl_snapshot()?;
rotate_session_file_if_needed(path)?;
write_atomic(path, &snapshot)?;
cleanup_rotated_logs(path)?;
fs::write(path, self.to_json().render())?;
Ok(())
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
let path = path.as_ref();
let contents = fs::read_to_string(path)?;
let session = match JsonValue::parse(&contents) {
Ok(value)
if value
.as_object()
.is_some_and(|object| object.contains_key("messages")) =>
{
Self::from_json(&value)?
}
Err(_) | Ok(_) => Self::from_jsonl(&contents)?,
};
Ok(session.with_persistence_path(path.to_path_buf()))
}
pub fn push_message(&mut self, message: ConversationMessage) -> Result<(), SessionError> {
self.touch();
self.messages.push(message);
let persist_result = {
let message_ref = self.messages.last().ok_or_else(|| {
SessionError::Format("message was just pushed but missing".to_string())
})?;
self.append_persisted_message(message_ref)
};
if let Err(error) = persist_result {
self.messages.pop();
return Err(error);
}
Ok(())
}
pub fn push_user_text(&mut self, text: impl Into<String>) -> Result<(), SessionError> {
self.push_message(ConversationMessage::user_text(text))
}
pub fn record_compaction(&mut self, summary: impl Into<String>, removed_message_count: usize) {
self.touch();
let count = self.compaction.as_ref().map_or(1, |value| value.count + 1);
self.compaction = Some(SessionCompaction {
count,
removed_message_count,
summary: summary.into(),
});
Self::from_json(&JsonValue::parse(&contents)?)
}
#[must_use]
pub fn fork(&self, branch_name: Option<String>) -> Self {
let now = current_time_millis();
Self {
version: self.version,
session_id: generate_session_id(),
created_at_ms: now,
updated_at_ms: now,
messages: self.messages.clone(),
compaction: self.compaction.clone(),
fork: Some(SessionFork {
parent_session_id: self.session_id.clone(),
branch_name: normalize_optional_string(branch_name),
}),
persistence: None,
}
}
pub fn to_json(&self) -> Result<JsonValue, SessionError> {
pub fn to_json(&self) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
"version".to_string(),
JsonValue::Number(i64::from(self.version)),
);
object.insert(
"session_id".to_string(),
JsonValue::String(self.session_id.clone()),
);
object.insert(
"created_at_ms".to_string(),
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?),
);
object.insert(
"updated_at_ms".to_string(),
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?),
);
object.insert(
"messages".to_string(),
JsonValue::Array(
@@ -256,13 +111,7 @@ impl Session {
.collect(),
),
);
if let Some(compaction) = &self.compaction {
object.insert("compaction".to_string(), compaction.to_json()?);
}
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
Ok(JsonValue::Object(object))
JsonValue::Object(object)
}
pub fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
@@ -282,178 +131,7 @@ impl Session {
.iter()
.map(ConversationMessage::from_json)
.collect::<Result<Vec<_>, _>>()?;
let now = current_time_millis();
let session_id = object
.get("session_id")
.and_then(JsonValue::as_str)
.map_or_else(generate_session_id, ToOwned::to_owned);
let created_at_ms = object
.get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()?
.unwrap_or(now);
let updated_at_ms = object
.get("updated_at_ms")
.map(|value| required_u64_from_value(value, "updated_at_ms"))
.transpose()?
.unwrap_or(created_at_ms);
let compaction = object
.get("compaction")
.map(SessionCompaction::from_json)
.transpose()?;
let fork = object.get("fork").map(SessionFork::from_json).transpose()?;
Ok(Self {
version,
session_id,
created_at_ms,
updated_at_ms,
messages,
compaction,
fork,
persistence: None,
})
}
fn from_jsonl(contents: &str) -> Result<Self, SessionError> {
let mut version = SESSION_VERSION;
let mut session_id = None;
let mut created_at_ms = None;
let mut updated_at_ms = None;
let mut messages = Vec::new();
let mut compaction = None;
let mut fork = None;
for (line_number, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let value = JsonValue::parse(line).map_err(|error| {
SessionError::Format(format!(
"invalid JSONL record at line {}: {}",
line_number + 1,
error
))
})?;
let object = value.as_object().ok_or_else(|| {
SessionError::Format(format!(
"JSONL record at line {} must be an object",
line_number + 1
))
})?;
match object
.get("type")
.and_then(JsonValue::as_str)
.ok_or_else(|| {
SessionError::Format(format!(
"JSONL record at line {} missing type",
line_number + 1
))
})? {
"session_meta" => {
version = required_u32(object, "version")?;
session_id = Some(required_string(object, "session_id")?);
created_at_ms = Some(required_u64(object, "created_at_ms")?);
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
}
"message" => {
let message_value = object.get("message").ok_or_else(|| {
SessionError::Format(format!(
"JSONL record at line {} missing message",
line_number + 1
))
})?;
messages.push(ConversationMessage::from_json(message_value)?);
}
"compaction" => {
compaction = Some(SessionCompaction::from_json(&JsonValue::Object(
object.clone(),
))?);
}
other => {
return Err(SessionError::Format(format!(
"unsupported JSONL record type at line {}: {other}",
line_number + 1
)))
}
}
}
let now = current_time_millis();
Ok(Self {
version,
session_id: session_id.unwrap_or_else(generate_session_id),
created_at_ms: created_at_ms.unwrap_or(now),
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
messages,
compaction,
fork,
persistence: None,
})
}
fn render_jsonl_snapshot(&self) -> Result<String, SessionError> {
let mut lines = vec![self.meta_record()?.render()];
if let Some(compaction) = &self.compaction {
lines.push(compaction.to_jsonl_record()?.render());
}
lines.extend(
self.messages
.iter()
.map(|message| message_record(message).render()),
);
let mut rendered = lines.join("\n");
rendered.push('\n');
Ok(rendered)
}
fn append_persisted_message(&self, message: &ConversationMessage) -> Result<(), SessionError> {
let Some(path) = self.persistence_path() else {
return Ok(());
};
let needs_bootstrap = !path.exists() || fs::metadata(path)?.len() == 0;
if needs_bootstrap {
self.save_to_path(path)?;
return Ok(());
}
let mut file = OpenOptions::new().append(true).open(path)?;
writeln!(file, "{}", message_record(message).render())?;
Ok(())
}
fn meta_record(&self) -> Result<JsonValue, SessionError> {
let mut object = BTreeMap::new();
object.insert(
"type".to_string(),
JsonValue::String("session_meta".to_string()),
);
object.insert(
"version".to_string(),
JsonValue::Number(i64::from(self.version)),
);
object.insert(
"session_id".to_string(),
JsonValue::String(self.session_id.clone()),
);
object.insert(
"created_at_ms".to_string(),
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?),
);
object.insert(
"updated_at_ms".to_string(),
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?),
);
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
Ok(JsonValue::Object(object))
}
fn touch(&mut self) {
self.updated_at_ms = current_time_millis();
Ok(Self { version, messages })
}
}
@@ -646,101 +324,6 @@ impl ContentBlock {
}
}
impl SessionCompaction {
pub fn to_json(&self) -> Result<JsonValue, SessionError> {
let mut object = BTreeMap::new();
object.insert(
"count".to_string(),
JsonValue::Number(i64::from(self.count)),
);
object.insert(
"removed_message_count".to_string(),
JsonValue::Number(i64_from_usize(
self.removed_message_count,
"removed_message_count",
)?),
);
object.insert(
"summary".to_string(),
JsonValue::String(self.summary.clone()),
);
Ok(JsonValue::Object(object))
}
pub fn to_jsonl_record(&self) -> Result<JsonValue, SessionError> {
let mut object = BTreeMap::new();
object.insert(
"type".to_string(),
JsonValue::String("compaction".to_string()),
);
object.insert(
"count".to_string(),
JsonValue::Number(i64::from(self.count)),
);
object.insert(
"removed_message_count".to_string(),
JsonValue::Number(i64_from_usize(
self.removed_message_count,
"removed_message_count",
)?),
);
object.insert(
"summary".to_string(),
JsonValue::String(self.summary.clone()),
);
Ok(JsonValue::Object(object))
}
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
let object = value
.as_object()
.ok_or_else(|| SessionError::Format("compaction must be an object".to_string()))?;
Ok(Self {
count: required_u32(object, "count")?,
removed_message_count: required_usize(object, "removed_message_count")?,
summary: required_string(object, "summary")?,
})
}
}
impl SessionFork {
#[must_use]
pub fn to_json(&self) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
"parent_session_id".to_string(),
JsonValue::String(self.parent_session_id.clone()),
);
if let Some(branch_name) = &self.branch_name {
object.insert(
"branch_name".to_string(),
JsonValue::String(branch_name.clone()),
);
}
JsonValue::Object(object)
}
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
let object = value
.as_object()
.ok_or_else(|| SessionError::Format("fork metadata must be an object".to_string()))?;
Ok(Self {
parent_session_id: required_string(object, "parent_session_id")?,
branch_name: object
.get("branch_name")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned),
})
}
}
fn message_record(message: &ConversationMessage) -> JsonValue {
let mut object = BTreeMap::new();
object.insert("type".to_string(), JsonValue::String("message".to_string()));
object.insert("message".to_string(), message.to_json());
JsonValue::Object(object)
}
fn usage_to_json(usage: TokenUsage) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
@@ -793,162 +376,22 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
u32::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
}
fn required_u64(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u64, SessionError> {
let value = object
.get(key)
.ok_or_else(|| SessionError::Format(format!("missing {key}")))?;
required_u64_from_value(value, key)
}
fn required_u64_from_value(value: &JsonValue, key: &str) -> Result<u64, SessionError> {
let value = value
.as_i64()
.ok_or_else(|| SessionError::Format(format!("missing {key}")))?;
u64::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
}
fn required_usize(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<usize, SessionError> {
let value = object
.get(key)
.and_then(JsonValue::as_i64)
.ok_or_else(|| SessionError::Format(format!("missing {key}")))?;
usize::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
}
fn i64_from_u64(value: u64, key: &str) -> Result<i64, SessionError> {
i64::try_from(value)
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
}
fn i64_from_usize(value: usize, key: &str) -> Result<i64, SessionError> {
i64::try_from(value)
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn current_time_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
.unwrap_or_default()
}
fn generate_session_id() -> String {
let millis = current_time_millis();
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("session-{millis}-{counter}")
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), SessionError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temporary_path_for(path);
fs::write(&temp_path, contents)?;
fs::rename(temp_path, path)?;
Ok(())
}
fn temporary_path_for(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("session");
path.with_file_name(format!(
"{file_name}.tmp-{}-{}",
current_time_millis(),
SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
fn rotate_session_file_if_needed(path: &Path) -> Result<(), SessionError> {
let Ok(metadata) = fs::metadata(path) else {
return Ok(());
};
if metadata.len() < ROTATE_AFTER_BYTES {
return Ok(());
}
let rotated_path = rotated_log_path(path);
fs::rename(path, rotated_path)?;
Ok(())
}
fn rotated_log_path(path: &Path) -> PathBuf {
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("session");
path.with_file_name(format!("{stem}.rot-{}.jsonl", current_time_millis()))
}
fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
let Some(parent) = path.parent() else {
return Ok(());
};
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("session");
let prefix = format!("{stem}.rot-");
let mut rotated_paths = fs::read_dir(parent)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|entry_path| {
entry_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
name.starts_with(&prefix)
&& Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
})
})
.collect::<Vec<_>>();
rotated_paths.sort_by_key(|entry_path| {
fs::metadata(entry_path)
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH)
});
let remove_count = rotated_paths.len().saturating_sub(MAX_ROTATED_FILES);
for stale_path in rotated_paths.into_iter().take(remove_count) {
fs::remove_file(stale_path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
cleanup_rotated_logs, rotate_session_file_if_needed, ContentBlock, ConversationMessage,
MessageRole, Session, SessionFork,
};
use crate::json::JsonValue;
use super::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::usage::TokenUsage;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn persists_and_restores_session_jsonl() {
fn persists_and_restores_session_json() {
let mut session = Session::new();
session
.push_user_text("hello")
.expect("user message should append");
.messages
.push(ConversationMessage::user_text("hello"));
session
.push_message(ConversationMessage::assistant_with_usage(
.messages
.push(ConversationMessage::assistant_with_usage(
vec![
ContentBlock::Text {
text: "thinking".to_string(),
@@ -965,15 +408,16 @@ mod tests {
cache_creation_input_tokens: 1,
cache_read_input_tokens: 2,
}),
))
.expect("assistant message should append");
session
.push_message(ConversationMessage::tool_result(
"tool-1", "bash", "hi", false,
))
.expect("tool result should append");
));
session.messages.push(ConversationMessage::tool_result(
"tool-1", "bash", "hi", false,
));
let path = temp_session_path("jsonl");
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("runtime-session-{nanos}.json"));
session.save_to_path(&path).expect("session should save");
let restored = Session::load_from_path(&path).expect("session should load");
fs::remove_file(&path).expect("temp file should be removable");
@@ -984,263 +428,5 @@ mod tests {
restored.messages[1].usage.expect("usage").total_tokens(),
17
);
assert_eq!(restored.session_id, session.session_id);
}
#[test]
fn loads_legacy_session_json_object() {
let path = temp_session_path("legacy");
let legacy = JsonValue::Object(
[
("version".to_string(), JsonValue::Number(1)),
(
"messages".to_string(),
JsonValue::Array(vec![ConversationMessage::user_text("legacy").to_json()]),
),
]
.into_iter()
.collect(),
);
fs::write(&path, legacy.render()).expect("legacy file should write");
let restored = Session::load_from_path(&path).expect("legacy session should load");
fs::remove_file(&path).expect("temp file should be removable");
assert_eq!(restored.messages.len(), 1);
assert_eq!(
restored.messages[0],
ConversationMessage::user_text("legacy")
);
assert!(!restored.session_id.is_empty());
}
#[test]
fn appends_messages_to_persisted_jsonl_session() {
let path = temp_session_path("append");
let mut session = Session::new().with_persistence_path(path.clone());
session
.save_to_path(&path)
.expect("initial save should succeed");
session
.push_user_text("hi")
.expect("user append should succeed");
session
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "hello".to_string(),
}]))
.expect("assistant append should succeed");
let restored = Session::load_from_path(&path).expect("session should replay from jsonl");
fs::remove_file(&path).expect("temp file should be removable");
assert_eq!(restored.messages.len(), 2);
assert_eq!(restored.messages[0], ConversationMessage::user_text("hi"));
}
#[test]
fn persists_compaction_metadata() {
let path = temp_session_path("compaction");
let mut session = Session::new();
session
.push_user_text("before")
.expect("message should append");
session.record_compaction("summarized earlier work", 4);
session.save_to_path(&path).expect("session should save");
let restored = Session::load_from_path(&path).expect("session should load");
fs::remove_file(&path).expect("temp file should be removable");
let compaction = restored.compaction.expect("compaction metadata");
assert_eq!(compaction.count, 1);
assert_eq!(compaction.removed_message_count, 4);
assert!(compaction.summary.contains("summarized"));
}
#[test]
fn forks_sessions_with_branch_metadata_and_persists_it() {
let path = temp_session_path("fork");
let mut session = Session::new();
session
.push_user_text("before fork")
.expect("message should append");
let forked = session
.fork(Some("investigation".to_string()))
.with_persistence_path(path.clone());
forked
.save_to_path(&path)
.expect("forked session should save");
let restored = Session::load_from_path(&path).expect("forked session should load");
fs::remove_file(&path).expect("temp file should be removable");
assert_ne!(restored.session_id, session.session_id);
assert_eq!(
restored.fork,
Some(SessionFork {
parent_session_id: session.session_id,
branch_name: Some("investigation".to_string()),
})
);
assert_eq!(restored.messages, forked.messages);
}
#[test]
fn rotates_and_cleans_up_large_session_logs() {
// given
let path = temp_session_path("rotation");
let oversized_length =
usize::try_from(super::ROTATE_AFTER_BYTES + 10).expect("rotate threshold should fit");
fs::write(&path, "x".repeat(oversized_length)).expect("oversized file should write");
// when
rotate_session_file_if_needed(&path).expect("rotation should succeed");
// then
assert!(
!path.exists(),
"original path should be rotated away before rewrite"
);
for _ in 0..5 {
let rotated = super::rotated_log_path(&path);
fs::write(&rotated, "old").expect("rotated file should write");
}
cleanup_rotated_logs(&path).expect("cleanup should succeed");
let rotated_count = rotation_files(&path).len();
assert!(rotated_count <= super::MAX_ROTATED_FILES);
for rotated in rotation_files(&path) {
fs::remove_file(rotated).expect("rotated file should be removable");
}
}
#[test]
fn rejects_jsonl_record_without_type() {
// given
let path = write_temp_session_file(
"missing-type",
r#"{"message":{"role":"user","blocks":[{"type":"text","text":"hello"}]}}"#,
);
// when
let error = Session::load_from_path(&path)
.expect_err("session should reject JSONL records without a type");
// then
assert!(error.to_string().contains("missing type"));
fs::remove_file(path).expect("temp file should be removable");
}
#[test]
fn rejects_jsonl_message_record_without_message_payload() {
// given
let path = write_temp_session_file("missing-message", r#"{"type":"message"}"#);
// when
let error = Session::load_from_path(&path)
.expect_err("session should reject JSONL message records without message payload");
// then
assert!(error.to_string().contains("missing message"));
fs::remove_file(path).expect("temp file should be removable");
}
#[test]
fn rejects_jsonl_record_with_unknown_type() {
// given
let path = write_temp_session_file("unknown-type", r#"{"type":"mystery"}"#);
// when
let error = Session::load_from_path(&path)
.expect_err("session should reject unknown JSONL record types");
// then
assert!(error.to_string().contains("unsupported JSONL record type"));
fs::remove_file(path).expect("temp file should be removable");
}
#[test]
fn rejects_legacy_session_json_without_messages() {
// given
let session = JsonValue::Object(
[("version".to_string(), JsonValue::Number(1))]
.into_iter()
.collect(),
);
// when
let error = Session::from_json(&session)
.expect_err("legacy session objects should require messages");
// then
assert!(error.to_string().contains("missing messages"));
}
#[test]
fn normalizes_blank_fork_branch_name_to_none() {
// given
let session = Session::new();
// when
let forked = session.fork(Some(" ".to_string()));
// then
assert_eq!(forked.fork.expect("fork metadata").branch_name, None);
}
#[test]
fn rejects_unknown_content_block_type() {
// given
let block = JsonValue::Object(
[("type".to_string(), JsonValue::String("unknown".to_string()))]
.into_iter()
.collect(),
);
// when
let error = ContentBlock::from_json(&block)
.expect_err("content blocks should reject unknown types");
// then
assert!(error.to_string().contains("unsupported block type"));
}
fn temp_session_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-session-{label}-{nanos}.json"))
}
fn write_temp_session_file(label: &str, contents: &str) -> PathBuf {
let path = temp_session_path(label);
fs::write(&path, format!("{contents}\n")).expect("temp session file should write");
path
}
fn rotation_files(path: &Path) -> Vec<PathBuf> {
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.expect("temp path should have file stem")
.to_string();
fs::read_dir(path.parent().expect("temp path should have parent"))
.expect("temp dir should read")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|entry_path| {
entry_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
name.starts_with(&format!("{stem}.rot-"))
&& Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
})
})
.collect()
}
}

View File

@@ -1,459 +0,0 @@
use std::env;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::session::{Session, SessionError};
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
pub const LEGACY_SESSION_EXTENSION: &str = "json";
pub const LATEST_SESSION_REFERENCE: &str = "latest";
const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionHandle {
pub id: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedSessionSummary {
pub id: String,
pub path: PathBuf,
pub modified_epoch_millis: u128,
pub message_count: usize,
pub parent_session_id: Option<String>,
pub branch_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedManagedSession {
pub handle: SessionHandle,
pub session: Session,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForkedManagedSession {
pub parent_session_id: String,
pub handle: SessionHandle,
pub session: Session,
pub branch_name: Option<String>,
}
#[derive(Debug)]
pub enum SessionControlError {
Io(std::io::Error),
Session(SessionError),
Format(String),
}
impl Display for SessionControlError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::Session(error) => write!(f, "{error}"),
Self::Format(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for SessionControlError {}
impl From<std::io::Error> for SessionControlError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<SessionError> for SessionControlError {
fn from(value: SessionError) -> Self {
Self::Session(value)
}
}
pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
managed_sessions_dir_for(env::current_dir()?)
}
pub fn managed_sessions_dir_for(
base_dir: impl AsRef<Path>,
) -> Result<PathBuf, SessionControlError> {
let path = base_dir.as_ref().join(".claw").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
}
pub fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, SessionControlError> {
create_managed_session_handle_for(env::current_dir()?, session_id)
}
pub fn create_managed_session_handle_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<SessionHandle, SessionControlError> {
let id = session_id.to_string();
let path =
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
Ok(SessionHandle { id, path })
}
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
resolve_session_reference_for(env::current_dir()?, reference)
}
pub fn resolve_session_reference_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<SessionHandle, SessionControlError> {
let base_dir = base_dir.as_ref();
if is_session_reference_alias(reference) {
let latest = latest_managed_session_for(base_dir)?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
});
}
let direct = PathBuf::from(reference);
let candidate = if direct.is_absolute() {
direct.clone()
} else {
base_dir.join(&direct)
};
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
let path = if candidate.exists() {
candidate
} else if looks_like_path {
return Err(SessionControlError::Format(
format_missing_session_reference(reference),
));
} else {
resolve_managed_session_path_for(base_dir, reference)?
};
Ok(SessionHandle {
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
path,
})
}
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
resolve_managed_session_path_for(env::current_dir()?, session_id)
}
pub fn resolve_managed_session_path_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<PathBuf, SessionControlError> {
let directory = managed_sessions_dir_for(base_dir)?;
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
let path = directory.join(format!("{session_id}.{extension}"));
if path.exists() {
return Ok(path);
}
}
Err(SessionControlError::Format(
format_missing_session_reference(session_id),
))
}
#[must_use]
pub fn is_managed_session_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|extension| {
extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION
})
}
pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
list_managed_sessions_for(env::current_dir()?)
}
pub fn list_managed_sessions_for(
base_dir: impl AsRef<Path>,
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
let entry = entry?;
let path = entry.path();
if !is_managed_session_file(&path) {
continue;
}
let metadata = entry.metadata()?;
let modified_epoch_millis = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) =
match Session::load_from_path(&path) {
Ok(session) => {
let parent_session_id = session
.fork
.as_ref()
.map(|fork| fork.parent_session_id.clone());
let branch_name = session
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
(
session.session_id,
session.messages.len(),
parent_session_id,
branch_name,
)
}
Err(_) => (
path.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
0,
None,
None,
),
};
sessions.push(ManagedSessionSummary {
id,
path,
modified_epoch_millis,
message_count,
parent_session_id,
branch_name,
});
}
sessions.sort_by(|left, right| {
right
.modified_epoch_millis
.cmp(&left.modified_epoch_millis)
.then_with(|| right.id.cmp(&left.id))
});
Ok(sessions)
}
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
latest_managed_session_for(env::current_dir()?)
}
pub fn latest_managed_session_for(
base_dir: impl AsRef<Path>,
) -> Result<ManagedSessionSummary, SessionControlError> {
list_managed_sessions_for(base_dir)?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
}
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
load_managed_session_for(env::current_dir()?, reference)
}
pub fn load_managed_session_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = resolve_session_reference_for(base_dir, reference)?;
let session = Session::load_from_path(&handle.path)?;
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_managed_session(
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
fork_managed_session_for(env::current_dir()?, session, branch_name)
}
pub fn fork_managed_session_for(
base_dir: impl AsRef<Path>,
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
let parent_session_id = session.session_id.clone();
let forked = session.fork(branch_name);
let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?;
let branch_name = forked
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
let forked = forked.with_persistence_path(handle.path.clone());
forked.save_to_path(&handle.path)?;
Ok(ForkedManagedSession {
parent_session_id,
handle,
session: forked,
branch_name,
})
}
#[must_use]
pub fn is_session_reference_alias(reference: &str) -> bool {
SESSION_REFERENCE_ALIASES
.iter()
.any(|alias| reference.eq_ignore_ascii_case(alias))
}
fn session_id_from_path(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.and_then(|name| {
name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}"))
.or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}")))
})
.map(ToOwned::to_owned)
}
fn format_missing_session_reference(reference: &str) -> String {
format!(
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
)
}
fn format_no_managed_sessions() -> String {
format!(
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
)
}
#[cfg(test)]
mod tests {
use super::{
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
ManagedSessionSummary, LATEST_SESSION_REFERENCE,
};
use crate::session::Session;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
}
fn persist_session(root: &Path, text: &str) -> Session {
let mut session = Session::new();
session
.push_user_text(text)
.expect("session message should save");
let handle = create_managed_session_handle_for(root, &session.session_id)
.expect("managed session handle should build");
let session = session.with_persistence_path(handle.path.clone());
session
.save_to_path(&handle.path)
.expect("session should persist");
session
}
fn wait_for_next_millisecond() {
let start = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_millis();
while SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_millis()
<= start
{}
}
fn summary_by_id<'a>(
summaries: &'a [ManagedSessionSummary],
id: &str,
) -> &'a ManagedSessionSummary {
summaries
.iter()
.find(|summary| summary.id == id)
.expect("session summary should exist")
}
#[test]
fn creates_and_lists_managed_sessions() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let older = persist_session(&root, "older session");
wait_for_next_millisecond();
let newer = persist_session(&root, "newer session");
// when
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
// then
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].id, newer.session_id);
assert_eq!(summary_by_id(&sessions, &older.session_id).message_count, 1);
assert_eq!(summary_by_id(&sessions, &newer.session_id).message_count, 1);
fs::remove_dir_all(root).expect("temp dir should clean up");
}
#[test]
fn resolves_latest_alias_and_loads_session_from_workspace_root() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let older = persist_session(&root, "older session");
wait_for_next_millisecond();
let newer = persist_session(&root, "newer session");
// when
let handle = resolve_session_reference_for(&root, LATEST_SESSION_REFERENCE)
.expect("latest alias should resolve");
let loaded = load_managed_session_for(&root, "recent")
.expect("recent alias should load the latest session");
// then
assert_eq!(handle.id, newer.session_id);
assert_eq!(loaded.handle.id, newer.session_id);
assert_eq!(loaded.session.messages.len(), 1);
assert_ne!(loaded.handle.id, older.session_id);
assert!(is_session_reference_alias("last"));
fs::remove_dir_all(root).expect("temp dir should clean up");
}
#[test]
fn forks_session_into_managed_storage_with_lineage() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let source = persist_session(&root, "parent session");
// when
let forked = fork_managed_session_for(&root, &source, Some("incident-review".to_string()))
.expect("session should fork");
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
let summary = summary_by_id(&sessions, &forked.handle.id);
// then
assert_eq!(forked.parent_session_id, source.session_id);
assert_eq!(forked.branch_name.as_deref(), Some("incident-review"));
assert_eq!(
summary.parent_session_id.as_deref(),
Some(source.session_id.as_str())
);
assert_eq!(summary.branch_name.as_deref(), Some("incident-review"));
assert_eq!(
forked.session.persistence_path(),
Some(forked.handle.path.as_path())
);
fs::remove_dir_all(root).expect("temp dir should clean up");
}
}

View File

@@ -80,11 +80,7 @@ impl IncrementalSseParser {
}
fn take_event(&mut self) -> Option<SseEvent> {
if self.data_lines.is_empty()
&& self.event_name.is_none()
&& self.id.is_none()
&& self.retry.is_none()
{
if self.data_lines.is_empty() && self.event_name.is_none() && self.id.is_none() && self.retry.is_none() {
return None;
}
@@ -106,13 +102,8 @@ mod tests {
#[test]
fn parses_streaming_events() {
// given
let mut parser = IncrementalSseParser::new();
// when
let first = parser.push_chunk("event: message\ndata: hel");
// then
assert!(first.is_empty());
let second = parser.push_chunk("lo\n\nid: 1\ndata: world\n\n");
@@ -134,25 +125,4 @@ mod tests {
]
);
}
#[test]
fn finish_flushes_a_trailing_event_without_separator() {
// given
let mut parser = IncrementalSseParser::new();
parser.push_chunk("event: message\ndata: trailing");
// when
let events = parser.finish();
// then
assert_eq!(
events,
vec![SseEvent {
event: Some("message".to_string()),
data: "trailing".to_string(),
id: None,
retry: None,
}]
);
}
}

View File

@@ -1,416 +0,0 @@
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchFreshness {
Fresh,
Stale {
commits_behind: usize,
missing_fixes: Vec<String>,
},
Diverged {
ahead: usize,
behind: usize,
missing_fixes: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleBranchPolicy {
AutoRebase,
AutoMergeForward,
WarnOnly,
Block,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleBranchEvent {
BranchStaleAgainstMain {
branch: String,
commits_behind: usize,
missing_fixes: Vec<String>,
},
RebaseAttempted {
branch: String,
result: String,
},
MergeForwardAttempted {
branch: String,
result: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleBranchAction {
Noop,
Warn { message: String },
Block { message: String },
Rebase,
MergeForward,
}
pub fn check_freshness(branch: &str, main_ref: &str) -> BranchFreshness {
check_freshness_in(branch, main_ref, Path::new("."))
}
pub fn apply_policy(freshness: &BranchFreshness, policy: StaleBranchPolicy) -> StaleBranchAction {
match freshness {
BranchFreshness::Fresh => StaleBranchAction::Noop,
BranchFreshness::Stale {
commits_behind,
missing_fixes,
} => match policy {
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
message: format!(
"Branch is {commits_behind} commit(s) behind main. Missing fixes: {}",
if missing_fixes.is_empty() {
"(none)".to_string()
} else {
missing_fixes.join("; ")
}
),
},
StaleBranchPolicy::Block => StaleBranchAction::Block {
message: format!(
"Branch is {commits_behind} commit(s) behind main and must be updated before proceeding."
),
},
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
},
BranchFreshness::Diverged {
ahead,
behind,
missing_fixes,
} => match policy {
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
message: format!(
"Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main. Missing fixes: {}",
format_missing_fixes(missing_fixes)
),
},
StaleBranchPolicy::Block => StaleBranchAction::Block {
message: format!(
"Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding. Missing fixes: {}",
format_missing_fixes(missing_fixes)
),
},
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
},
}
}
pub(crate) fn check_freshness_in(
branch: &str,
main_ref: &str,
repo_path: &Path,
) -> BranchFreshness {
let behind = rev_list_count(main_ref, branch, repo_path);
let ahead = rev_list_count(branch, main_ref, repo_path);
if behind == 0 {
return BranchFreshness::Fresh;
}
if ahead > 0 {
return BranchFreshness::Diverged {
ahead,
behind,
missing_fixes: missing_fix_subjects(main_ref, branch, repo_path),
};
}
let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
BranchFreshness::Stale {
commits_behind: behind,
missing_fixes,
}
}
fn format_missing_fixes(missing_fixes: &[String]) -> String {
if missing_fixes.is_empty() {
"(none)".to_string()
} else {
missing_fixes.join("; ")
}
}
fn rev_list_count(a: &str, b: &str, repo_path: &Path) -> usize {
let output = Command::new("git")
.args(["rev-list", "--count", &format!("{b}..{a}")])
.current_dir(repo_path)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<usize>()
.unwrap_or(0),
_ => 0,
}
}
fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec<String> {
let output = Command::new("git")
.args(["log", "--format=%s", &format!("{b}..{a}")])
.current_dir(repo_path)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-stale-branch-{nanos}"))
}
fn init_repo(path: &Path) {
fs::create_dir_all(path).expect("create repo dir");
run(path, &["init", "--quiet", "-b", "main"]);
run(path, &["config", "user.email", "tests@example.com"]);
run(path, &["config", "user.name", "Stale Branch Tests"]);
fs::write(path.join("init.txt"), "initial\n").expect("write init file");
run(path, &["add", "."]);
run(path, &["commit", "-m", "initial commit", "--quiet"]);
}
fn run(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
assert!(
status.success(),
"git {} exited with {status}",
args.join(" ")
);
}
fn commit_file(repo: &Path, name: &str, msg: &str) {
fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
run(repo, &["add", name]);
run(repo, &["commit", "-m", msg, "--quiet"]);
}
#[test]
fn fresh_branch_passes() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
assert_eq!(freshness, BranchFreshness::Fresh);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn fresh_branch_ahead_of_main_still_fresh() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
commit_file(&root, "feature.txt", "add feature");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
assert_eq!(freshness, BranchFreshness::Fresh);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn stale_branch_detected_with_correct_behind_count_and_missing_fixes() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
run(&root, &["checkout", "main"]);
commit_file(&root, "fix1.txt", "fix: resolve timeout");
commit_file(&root, "fix2.txt", "fix: handle null pointer");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
match freshness {
BranchFreshness::Stale {
commits_behind,
missing_fixes,
} => {
assert_eq!(commits_behind, 2);
assert_eq!(missing_fixes.len(), 2);
assert_eq!(missing_fixes[0], "fix: handle null pointer");
assert_eq!(missing_fixes[1], "fix: resolve timeout");
}
other => panic!("expected Stale, got {other:?}"),
}
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn diverged_branch_detection() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
commit_file(&root, "topic_work.txt", "topic work");
run(&root, &["checkout", "main"]);
commit_file(&root, "main_fix.txt", "main fix");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
match freshness {
BranchFreshness::Diverged {
ahead,
behind,
missing_fixes,
} => {
assert_eq!(ahead, 1);
assert_eq!(behind, 1);
assert_eq!(missing_fixes, vec!["main fix".to_string()]);
}
other => panic!("expected Diverged, got {other:?}"),
}
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn policy_noop_for_fresh_branch() {
// given
let freshness = BranchFreshness::Fresh;
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
assert_eq!(action, StaleBranchAction::Noop);
}
#[test]
fn policy_warn_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 3,
missing_fixes: vec!["fix: timeout".into(), "fix: null ptr".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("3 commit(s) behind"));
assert!(message.contains("fix: timeout"));
assert!(message.contains("fix: null ptr"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn policy_block_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 1,
missing_fixes: vec!["hotfix".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::Block);
// then
match action {
StaleBranchAction::Block { message } => {
assert!(message.contains("1 commit(s) behind"));
}
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn policy_auto_rebase_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 2,
missing_fixes: vec![],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::AutoRebase);
// then
assert_eq!(action, StaleBranchAction::Rebase);
}
#[test]
fn policy_auto_merge_forward_for_diverged_branch() {
// given
let freshness = BranchFreshness::Diverged {
ahead: 5,
behind: 2,
missing_fixes: vec!["fix: merge main".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::AutoMergeForward);
// then
assert_eq!(action, StaleBranchAction::MergeForward);
}
#[test]
fn policy_warn_for_diverged_branch() {
// given
let freshness = BranchFreshness::Diverged {
ahead: 3,
behind: 1,
missing_fixes: vec!["main hotfix".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("diverged"));
assert!(message.contains("3 commit(s) ahead"));
assert!(message.contains("1 commit(s) behind"));
assert!(message.contains("main hotfix"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
}

View File

@@ -1,300 +0,0 @@
use std::collections::BTreeSet;
const DEFAULT_MAX_CHARS: usize = 1_200;
const DEFAULT_MAX_LINES: usize = 24;
const DEFAULT_MAX_LINE_CHARS: usize = 160;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummaryCompressionBudget {
pub max_chars: usize,
pub max_lines: usize,
pub max_line_chars: usize,
}
impl Default for SummaryCompressionBudget {
fn default() -> Self {
Self {
max_chars: DEFAULT_MAX_CHARS,
max_lines: DEFAULT_MAX_LINES,
max_line_chars: DEFAULT_MAX_LINE_CHARS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryCompressionResult {
pub summary: String,
pub original_chars: usize,
pub compressed_chars: usize,
pub original_lines: usize,
pub compressed_lines: usize,
pub removed_duplicate_lines: usize,
pub omitted_lines: usize,
pub truncated: bool,
}
#[must_use]
pub fn compress_summary(
summary: &str,
budget: SummaryCompressionBudget,
) -> SummaryCompressionResult {
let original_chars = summary.chars().count();
let original_lines = summary.lines().count();
let normalized = normalize_lines(summary, budget.max_line_chars);
if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
return SummaryCompressionResult {
summary: String::new(),
original_chars,
compressed_chars: 0,
original_lines,
compressed_lines: 0,
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines: normalized.lines.len(),
truncated: original_chars > 0,
};
}
let selected = select_line_indexes(&normalized.lines, budget);
let mut compressed_lines = selected
.iter()
.map(|index| normalized.lines[*index].clone())
.collect::<Vec<_>>();
if compressed_lines.is_empty() {
compressed_lines.push(truncate_line(&normalized.lines[0], budget.max_chars));
}
let omitted_lines = normalized
.lines
.len()
.saturating_sub(compressed_lines.len());
if omitted_lines > 0 {
let omission_notice = omission_notice(omitted_lines);
push_line_with_budget(&mut compressed_lines, omission_notice, budget);
}
let compressed_summary = compressed_lines.join("\n");
SummaryCompressionResult {
compressed_chars: compressed_summary.chars().count(),
compressed_lines: compressed_lines.len(),
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines,
truncated: compressed_summary != summary.trim(),
summary: compressed_summary,
original_chars,
original_lines,
}
}
#[must_use]
pub fn compress_summary_text(summary: &str) -> String {
compress_summary(summary, SummaryCompressionBudget::default()).summary
}
#[derive(Debug, Default)]
struct NormalizedSummary {
lines: Vec<String>,
removed_duplicate_lines: usize,
}
fn normalize_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
let mut seen = BTreeSet::new();
let mut lines = Vec::new();
let mut removed_duplicate_lines = 0;
for raw_line in summary.lines() {
let normalized = collapse_inline_whitespace(raw_line);
if normalized.is_empty() {
continue;
}
let truncated = truncate_line(&normalized, max_line_chars);
let dedupe_key = dedupe_key(&truncated);
if !seen.insert(dedupe_key) {
removed_duplicate_lines += 1;
continue;
}
lines.push(truncated);
}
NormalizedSummary {
lines,
removed_duplicate_lines,
}
}
fn select_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
let mut selected = BTreeSet::<usize>::new();
for priority in 0..=3 {
for (index, line) in lines.iter().enumerate() {
if selected.contains(&index) || line_priority(line) != priority {
continue;
}
let candidate = selected
.iter()
.map(|selected_index| lines[*selected_index].as_str())
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() > budget.max_lines {
continue;
}
if joined_char_count(&candidate) > budget.max_chars {
continue;
}
selected.insert(index);
}
}
selected.into_iter().collect()
}
fn push_line_with_budget(lines: &mut Vec<String>, line: String, budget: SummaryCompressionBudget) {
let candidate = lines
.iter()
.map(String::as_str)
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() <= budget.max_lines && joined_char_count(&candidate) <= budget.max_chars {
lines.push(line);
}
}
fn joined_char_count(lines: &[&str]) -> usize {
lines.iter().map(|line| line.chars().count()).sum::<usize>() + lines.len().saturating_sub(1)
}
fn line_priority(line: &str) -> usize {
if line == "Summary:" || line == "Conversation summary:" || is_core_detail(line) {
0
} else if is_section_header(line) {
1
} else if line.starts_with("- ") || line.starts_with(" - ") {
2
} else {
3
}
}
fn is_core_detail(line: &str) -> bool {
[
"- Scope:",
"- Current work:",
"- Pending work:",
"- Key files referenced:",
"- Tools mentioned:",
"- Recent user requests:",
"- Previously compacted context:",
"- Newly compacted context:",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
fn is_section_header(line: &str) -> bool {
line.ends_with(':')
}
fn omission_notice(omitted_lines: usize) -> String {
format!("- … {omitted_lines} additional line(s) omitted.")
}
fn collapse_inline_whitespace(line: &str) -> String {
line.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_line(line: &str, max_chars: usize) -> String {
if max_chars == 0 || line.chars().count() <= max_chars {
return line.to_string();
}
if max_chars == 1 {
return "".to_string();
}
let mut truncated = line
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
truncated.push('…');
truncated
}
fn dedupe_key(line: &str) -> String {
line.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::{compress_summary, compress_summary_text, SummaryCompressionBudget};
#[test]
fn collapses_whitespace_and_duplicate_lines() {
// given
let summary = "Conversation summary:\n\n- Scope: compact earlier messages.\n- Scope: compact earlier messages.\n- Current work: update runtime module.\n";
// when
let result = compress_summary(summary, SummaryCompressionBudget::default());
// then
assert_eq!(result.removed_duplicate_lines, 1);
assert!(result
.summary
.contains("- Scope: compact earlier messages."));
assert!(!result.summary.contains(" compact earlier"));
}
#[test]
fn keeps_core_lines_when_budget_is_tight() {
// given
let summary = [
"Conversation summary:",
"- Scope: 18 earlier messages compacted.",
"- Current work: finish summary compression.",
"- Key timeline:",
" - user: asked for a working implementation.",
" - assistant: inspected runtime compaction flow.",
" - tool: cargo check succeeded.",
]
.join("\n");
// when
let result = compress_summary(
&summary,
SummaryCompressionBudget {
max_chars: 120,
max_lines: 3,
max_line_chars: 80,
},
);
// then
assert!(result.summary.contains("Conversation summary:"));
assert!(result
.summary
.contains("- Scope: 18 earlier messages compacted."));
assert!(result
.summary
.contains("- Current work: finish summary compression."));
assert!(result.omitted_lines > 0);
}
#[test]
fn provides_a_default_text_only_helper() {
// given
let summary = "Summary:\n\nA short line.";
// when
let compressed = compress_summary_text(summary);
// then
assert_eq!(compressed, "Summary:\nA short line.");
}
}

View File

@@ -1,162 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskPacket {
pub objective: String,
pub scope: String,
pub repo: String,
pub branch_policy: String,
pub acceptance_tests: Vec<String>,
pub commit_policy: String,
pub reporting_contract: String,
pub escalation_policy: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPacketValidationError {
errors: Vec<String>,
}
impl TaskPacketValidationError {
#[must_use]
pub fn new(errors: Vec<String>) -> Self {
Self { errors }
}
#[must_use]
pub fn errors(&self) -> &[String] {
&self.errors
}
}
impl Display for TaskPacketValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.errors.join("; "))
}
}
impl std::error::Error for TaskPacketValidationError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedPacket(TaskPacket);
impl ValidatedPacket {
#[must_use]
pub fn packet(&self) -> &TaskPacket {
&self.0
}
#[must_use]
pub fn into_inner(self) -> TaskPacket {
self.0
}
}
pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacketValidationError> {
let mut errors = Vec::new();
validate_required("objective", &packet.objective, &mut errors);
validate_required("scope", &packet.scope, &mut errors);
validate_required("repo", &packet.repo, &mut errors);
validate_required("branch_policy", &packet.branch_policy, &mut errors);
validate_required("commit_policy", &packet.commit_policy, &mut errors);
validate_required(
"reporting_contract",
&packet.reporting_contract,
&mut errors,
);
validate_required(
"escalation_policy",
&packet.escalation_policy,
&mut errors,
);
for (index, test) in packet.acceptance_tests.iter().enumerate() {
if test.trim().is_empty() {
errors.push(format!(
"acceptance_tests contains an empty value at index {index}"
));
}
}
if errors.is_empty() {
Ok(ValidatedPacket(packet))
} else {
Err(TaskPacketValidationError::new(errors))
}
}
fn validate_required(field: &str, value: &str, errors: &mut Vec<String>) {
if value.trim().is_empty() {
errors.push(format!("{field} must not be empty"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_packet() -> TaskPacket {
TaskPacket {
objective: "Implement typed task packet format".to_string(),
scope: "runtime/task system".to_string(),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec![
"cargo build --workspace".to_string(),
"cargo test --workspace".to_string(),
],
commit_policy: "single verified commit".to_string(),
reporting_contract: "print build result, test result, commit sha".to_string(),
escalation_policy: "stop only on destructive ambiguity".to_string(),
}
}
#[test]
fn valid_packet_passes_validation() {
let packet = sample_packet();
let validated = validate_packet(packet.clone()).expect("packet should validate");
assert_eq!(validated.packet(), &packet);
assert_eq!(validated.into_inner(), packet);
}
#[test]
fn invalid_packet_accumulates_errors() {
let packet = TaskPacket {
objective: " ".to_string(),
scope: String::new(),
repo: String::new(),
branch_policy: "\t".to_string(),
acceptance_tests: vec!["ok".to_string(), " ".to_string()],
commit_policy: String::new(),
reporting_contract: String::new(),
escalation_policy: String::new(),
};
let error = validate_packet(packet).expect_err("packet should be rejected");
assert!(error.errors().len() >= 7);
assert!(error
.errors()
.contains(&"objective must not be empty".to_string()));
assert!(error
.errors()
.contains(&"scope must not be empty".to_string()));
assert!(error
.errors()
.contains(&"repo must not be empty".to_string()));
assert!(error.errors().contains(
&"acceptance_tests contains an empty value at index 1".to_string()
));
}
#[test]
fn serialization_roundtrip_preserves_packet() {
let packet = sample_packet();
let serialized = serde_json::to_string(&packet).expect("packet should serialize");
let deserialized: TaskPacket =
serde_json::from_str(&serialized).expect("packet should deserialize");
assert_eq!(deserialized, packet);
}
}

View File

@@ -1,506 +0,0 @@
//! In-memory task registry for sub-agent task lifecycle management.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Created,
Running,
Completed,
Failed,
Stopped,
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub task_id: String,
pub prompt: String,
pub description: Option<String>,
pub task_packet: Option<TaskPacket>,
pub status: TaskStatus,
pub created_at: u64,
pub updated_at: u64,
pub messages: Vec<TaskMessage>,
pub output: String,
pub team_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskMessage {
pub role: String,
pub content: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Default)]
pub struct TaskRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
#[derive(Debug, Default)]
struct RegistryInner {
tasks: HashMap<String, Task>,
counter: u64,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl TaskRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(
prompt.to_owned(),
description.map(str::to_owned),
None,
)
}
pub fn create_from_packet(
&self,
packet: TaskPacket,
) -> Result<Task, TaskPacketValidationError> {
let packet = validate_packet(packet)?.into_inner();
Ok(self.create_task(
packet.objective.clone(),
Some(packet.scope.clone()),
Some(packet),
))
}
fn create_task(
&self,
prompt: String,
description: Option<String>,
task_packet: Option<TaskPacket>,
) -> Task {
let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let task_id = format!("task_{:08x}_{}", ts, inner.counter);
let task = Task {
task_id: task_id.clone(),
prompt,
description,
task_packet,
status: TaskStatus::Created,
created_at: ts,
updated_at: ts,
messages: Vec::new(),
output: String::new(),
team_id: None,
};
inner.tasks.insert(task_id, task.clone());
task
}
pub fn get(&self, task_id: &str) -> Option<Task> {
let inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.get(task_id).cloned()
}
pub fn list(&self, status_filter: Option<TaskStatus>) -> Vec<Task> {
let inner = self.inner.lock().expect("registry lock poisoned");
inner
.tasks
.values()
.filter(|t| status_filter.map_or(true, |s| t.status == s))
.cloned()
.collect()
}
pub fn stop(&self, task_id: &str) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
match task.status {
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Stopped => {
return Err(format!(
"task {task_id} is already in terminal state: {}",
task.status
));
}
_ => {}
}
task.status = TaskStatus::Stopped;
task.updated_at = now_secs();
Ok(task.clone())
}
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.messages.push(TaskMessage {
role: String::from("user"),
content: message.to_owned(),
timestamp: now_secs(),
});
task.updated_at = now_secs();
Ok(task.clone())
}
pub fn output(&self, task_id: &str) -> Result<String, String> {
let inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
Ok(task.output.clone())
}
pub fn append_output(&self, task_id: &str, output: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.output.push_str(output);
task.updated_at = now_secs();
Ok(())
}
pub fn set_status(&self, task_id: &str, status: TaskStatus) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.status = status;
task.updated_at = now_secs();
Ok(())
}
pub fn assign_team(&self, task_id: &str, team_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.team_id = Some(team_id.to_owned());
task.updated_at = now_secs();
Ok(())
}
pub fn remove(&self, task_id: &str) -> Option<Task> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.remove(task_id)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_and_retrieves_tasks() {
let registry = TaskRegistry::new();
let task = registry.create("Do something", Some("A test task"));
assert_eq!(task.status, TaskStatus::Created);
assert_eq!(task.prompt, "Do something");
assert_eq!(task.description.as_deref(), Some("A test task"));
assert_eq!(task.task_packet, None);
let fetched = registry.get(&task.task_id).expect("task should exist");
assert_eq!(fetched.task_id, task.task_id);
}
#[test]
fn creates_task_from_packet() {
let registry = TaskRegistry::new();
let packet = TaskPacket {
objective: "Ship task packet support".to_string(),
scope: "runtime/task system".to_string(),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec!["cargo test --workspace".to_string()],
commit_policy: "single commit".to_string(),
reporting_contract: "print commit sha".to_string(),
escalation_policy: "manual escalation".to_string(),
};
let task = registry
.create_from_packet(packet.clone())
.expect("packet-backed task should be created");
assert_eq!(task.prompt, packet.objective);
assert_eq!(task.description.as_deref(), Some("runtime/task system"));
assert_eq!(task.task_packet, Some(packet.clone()));
let fetched = registry.get(&task.task_id).expect("task should exist");
assert_eq!(fetched.task_packet, Some(packet));
}
#[test]
fn lists_tasks_with_optional_filter() {
let registry = TaskRegistry::new();
registry.create("Task A", None);
let task_b = registry.create("Task B", None);
registry
.set_status(&task_b.task_id, TaskStatus::Running)
.expect("set status should succeed");
let all = registry.list(None);
assert_eq!(all.len(), 2);
let running = registry.list(Some(TaskStatus::Running));
assert_eq!(running.len(), 1);
assert_eq!(running[0].task_id, task_b.task_id);
let created = registry.list(Some(TaskStatus::Created));
assert_eq!(created.len(), 1);
}
#[test]
fn stops_running_task() {
let registry = TaskRegistry::new();
let task = registry.create("Stoppable", None);
registry
.set_status(&task.task_id, TaskStatus::Running)
.unwrap();
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
assert_eq!(stopped.status, TaskStatus::Stopped);
// Stopping again should fail
let result = registry.stop(&task.task_id);
assert!(result.is_err());
}
#[test]
fn updates_task_with_messages() {
let registry = TaskRegistry::new();
let task = registry.create("Messageable", None);
let updated = registry
.update(&task.task_id, "Here's more context")
.expect("update should succeed");
assert_eq!(updated.messages.len(), 1);
assert_eq!(updated.messages[0].content, "Here's more context");
assert_eq!(updated.messages[0].role, "user");
}
#[test]
fn appends_and_retrieves_output() {
let registry = TaskRegistry::new();
let task = registry.create("Output task", None);
registry
.append_output(&task.task_id, "line 1\n")
.expect("append should succeed");
registry
.append_output(&task.task_id, "line 2\n")
.expect("append should succeed");
let output = registry.output(&task.task_id).expect("output should exist");
assert_eq!(output, "line 1\nline 2\n");
}
#[test]
fn assigns_team_and_removes_task() {
let registry = TaskRegistry::new();
let task = registry.create("Team task", None);
registry
.assign_team(&task.task_id, "team_abc")
.expect("assign should succeed");
let fetched = registry.get(&task.task_id).unwrap();
assert_eq!(fetched.team_id.as_deref(), Some("team_abc"));
let removed = registry.remove(&task.task_id);
assert!(removed.is_some());
assert!(registry.get(&task.task_id).is_none());
assert!(registry.is_empty());
}
#[test]
fn rejects_operations_on_missing_task() {
let registry = TaskRegistry::new();
assert!(registry.stop("nonexistent").is_err());
assert!(registry.update("nonexistent", "msg").is_err());
assert!(registry.output("nonexistent").is_err());
assert!(registry.append_output("nonexistent", "data").is_err());
assert!(registry
.set_status("nonexistent", TaskStatus::Running)
.is_err());
}
#[test]
fn task_status_display_all_variants() {
// given
let cases = [
(TaskStatus::Created, "created"),
(TaskStatus::Running, "running"),
(TaskStatus::Completed, "completed"),
(TaskStatus::Failed, "failed"),
(TaskStatus::Stopped, "stopped"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("created".to_string(), "created"),
("running".to_string(), "running"),
("completed".to_string(), "completed"),
("failed".to_string(), "failed"),
("stopped".to_string(), "stopped"),
]
);
}
#[test]
fn stop_rejects_completed_task() {
// given
let registry = TaskRegistry::new();
let task = registry.create("done", None);
registry
.set_status(&task.task_id, TaskStatus::Completed)
.expect("set status should succeed");
// when
let result = registry.stop(&task.task_id);
// then
let error = result.expect_err("completed task should be rejected");
assert!(error.contains("already in terminal state"));
assert!(error.contains("completed"));
}
#[test]
fn stop_rejects_failed_task() {
// given
let registry = TaskRegistry::new();
let task = registry.create("failed", None);
registry
.set_status(&task.task_id, TaskStatus::Failed)
.expect("set status should succeed");
// when
let result = registry.stop(&task.task_id);
// then
let error = result.expect_err("failed task should be rejected");
assert!(error.contains("already in terminal state"));
assert!(error.contains("failed"));
}
#[test]
fn stop_succeeds_from_created_state() {
// given
let registry = TaskRegistry::new();
let task = registry.create("created task", None);
// when
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
// then
assert_eq!(stopped.status, TaskStatus::Stopped);
assert!(stopped.updated_at >= task.updated_at);
}
#[test]
fn new_registry_is_empty() {
// given
let registry = TaskRegistry::new();
// when
let all_tasks = registry.list(None);
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(all_tasks.is_empty());
}
#[test]
fn create_without_description() {
// given
let registry = TaskRegistry::new();
// when
let task = registry.create("Do the thing", None);
// then
assert!(task.task_id.starts_with("task_"));
assert_eq!(task.description, None);
assert_eq!(task.task_packet, None);
assert!(task.messages.is_empty());
assert!(task.output.is_empty());
assert_eq!(task.team_id, None);
}
#[test]
fn remove_nonexistent_returns_none() {
// given
let registry = TaskRegistry::new();
// when
let removed = registry.remove("missing");
// then
assert!(removed.is_none());
}
#[test]
fn assign_team_rejects_missing_task() {
// given
let registry = TaskRegistry::new();
// when
let result = registry.assign_team("missing", "team_123");
// then
let error = result.expect_err("missing task should be rejected");
assert_eq!(error, "task not found: missing");
}
}

View File

@@ -1,508 +0,0 @@
//! In-memory registries for Team and Cron lifecycle management.
//!
//! Provides TeamCreate/Delete and CronCreate/Delete/List runtime backing
//! to replace the stub implementations in the tools crate.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Team {
pub team_id: String,
pub name: String,
pub task_ids: Vec<String>,
pub status: TeamStatus,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeamStatus {
Created,
Running,
Completed,
Deleted,
}
impl std::fmt::Display for TeamStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Deleted => write!(f, "deleted"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TeamRegistry {
inner: Arc<Mutex<TeamInner>>,
}
#[derive(Debug, Default)]
struct TeamInner {
teams: HashMap<String, Team>,
counter: u64,
}
impl TeamRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, name: &str, task_ids: Vec<String>) -> Team {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let team_id = format!("team_{:08x}_{}", ts, inner.counter);
let team = Team {
team_id: team_id.clone(),
name: name.to_owned(),
task_ids,
status: TeamStatus::Created,
created_at: ts,
updated_at: ts,
};
inner.teams.insert(team_id, team.clone());
team
}
pub fn get(&self, team_id: &str) -> Option<Team> {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.get(team_id).cloned()
}
pub fn list(&self) -> Vec<Team> {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.values().cloned().collect()
}
pub fn delete(&self, team_id: &str) -> Result<Team, String> {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
let team = inner
.teams
.get_mut(team_id)
.ok_or_else(|| format!("team not found: {team_id}"))?;
team.status = TeamStatus::Deleted;
team.updated_at = now_secs();
Ok(team.clone())
}
pub fn remove(&self, team_id: &str) -> Option<Team> {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.remove(team_id)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronEntry {
pub cron_id: String,
pub schedule: String,
pub prompt: String,
pub description: Option<String>,
pub enabled: bool,
pub created_at: u64,
pub updated_at: u64,
pub last_run_at: Option<u64>,
pub run_count: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CronRegistry {
inner: Arc<Mutex<CronInner>>,
}
#[derive(Debug, Default)]
struct CronInner {
entries: HashMap<String, CronEntry>,
counter: u64,
}
impl CronRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, schedule: &str, prompt: &str, description: Option<&str>) -> CronEntry {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let cron_id = format!("cron_{:08x}_{}", ts, inner.counter);
let entry = CronEntry {
cron_id: cron_id.clone(),
schedule: schedule.to_owned(),
prompt: prompt.to_owned(),
description: description.map(str::to_owned),
enabled: true,
created_at: ts,
updated_at: ts,
last_run_at: None,
run_count: 0,
};
inner.entries.insert(cron_id, entry.clone());
entry
}
pub fn get(&self, cron_id: &str) -> Option<CronEntry> {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner.entries.get(cron_id).cloned()
}
pub fn list(&self, enabled_only: bool) -> Vec<CronEntry> {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner
.entries
.values()
.filter(|e| !enabled_only || e.enabled)
.cloned()
.collect()
}
pub fn delete(&self, cron_id: &str) -> Result<CronEntry, String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
inner
.entries
.remove(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))
}
/// Disable a cron entry without removing it.
pub fn disable(&self, cron_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
let entry = inner
.entries
.get_mut(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
entry.enabled = false;
entry.updated_at = now_secs();
Ok(())
}
/// Record a cron run.
pub fn record_run(&self, cron_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
let entry = inner
.entries
.get_mut(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
entry.last_run_at = Some(now_secs());
entry.run_count += 1;
entry.updated_at = now_secs();
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Team tests ──────────────────────────────────────
#[test]
fn creates_and_retrieves_team() {
let registry = TeamRegistry::new();
let team = registry.create("Alpha Squad", vec!["task_001".into(), "task_002".into()]);
assert_eq!(team.name, "Alpha Squad");
assert_eq!(team.task_ids.len(), 2);
assert_eq!(team.status, TeamStatus::Created);
let fetched = registry.get(&team.team_id).expect("team should exist");
assert_eq!(fetched.team_id, team.team_id);
}
#[test]
fn lists_and_deletes_teams() {
let registry = TeamRegistry::new();
let t1 = registry.create("Team A", vec![]);
let t2 = registry.create("Team B", vec![]);
let all = registry.list();
assert_eq!(all.len(), 2);
let deleted = registry.delete(&t1.team_id).expect("delete should succeed");
assert_eq!(deleted.status, TeamStatus::Deleted);
// Team is still listable (soft delete)
let still_there = registry.get(&t1.team_id).unwrap();
assert_eq!(still_there.status, TeamStatus::Deleted);
// Hard remove
registry.remove(&t2.team_id);
assert_eq!(registry.len(), 1);
}
#[test]
fn rejects_missing_team_operations() {
let registry = TeamRegistry::new();
assert!(registry.delete("nonexistent").is_err());
assert!(registry.get("nonexistent").is_none());
}
// ── Cron tests ──────────────────────────────────────
#[test]
fn creates_and_retrieves_cron() {
let registry = CronRegistry::new();
let entry = registry.create("0 * * * *", "Check status", Some("hourly check"));
assert_eq!(entry.schedule, "0 * * * *");
assert_eq!(entry.prompt, "Check status");
assert!(entry.enabled);
assert_eq!(entry.run_count, 0);
assert!(entry.last_run_at.is_none());
let fetched = registry.get(&entry.cron_id).expect("cron should exist");
assert_eq!(fetched.cron_id, entry.cron_id);
}
#[test]
fn lists_with_enabled_filter() {
let registry = CronRegistry::new();
let c1 = registry.create("* * * * *", "Task 1", None);
let c2 = registry.create("0 * * * *", "Task 2", None);
registry
.disable(&c1.cron_id)
.expect("disable should succeed");
let all = registry.list(false);
assert_eq!(all.len(), 2);
let enabled_only = registry.list(true);
assert_eq!(enabled_only.len(), 1);
assert_eq!(enabled_only[0].cron_id, c2.cron_id);
}
#[test]
fn deletes_cron_entry() {
let registry = CronRegistry::new();
let entry = registry.create("* * * * *", "To delete", None);
let deleted = registry
.delete(&entry.cron_id)
.expect("delete should succeed");
assert_eq!(deleted.cron_id, entry.cron_id);
assert!(registry.get(&entry.cron_id).is_none());
assert!(registry.is_empty());
}
#[test]
fn records_cron_runs() {
let registry = CronRegistry::new();
let entry = registry.create("*/5 * * * *", "Recurring", None);
registry.record_run(&entry.cron_id).unwrap();
registry.record_run(&entry.cron_id).unwrap();
let fetched = registry.get(&entry.cron_id).unwrap();
assert_eq!(fetched.run_count, 2);
assert!(fetched.last_run_at.is_some());
}
#[test]
fn rejects_missing_cron_operations() {
let registry = CronRegistry::new();
assert!(registry.delete("nonexistent").is_err());
assert!(registry.disable("nonexistent").is_err());
assert!(registry.record_run("nonexistent").is_err());
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn team_status_display_all_variants() {
// given
let cases = [
(TeamStatus::Created, "created"),
(TeamStatus::Running, "running"),
(TeamStatus::Completed, "completed"),
(TeamStatus::Deleted, "deleted"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("created".to_string(), "created"),
("running".to_string(), "running"),
("completed".to_string(), "completed"),
("deleted".to_string(), "deleted"),
]
);
}
#[test]
fn new_team_registry_is_empty() {
// given
let registry = TeamRegistry::new();
// when
let teams = registry.list();
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(teams.is_empty());
}
#[test]
fn team_remove_nonexistent_returns_none() {
// given
let registry = TeamRegistry::new();
// when
let removed = registry.remove("missing");
// then
assert!(removed.is_none());
}
#[test]
fn team_len_transitions() {
// given
let registry = TeamRegistry::new();
// when
let alpha = registry.create("Alpha", vec![]);
let beta = registry.create("Beta", vec![]);
let after_create = registry.len();
registry.remove(&alpha.team_id);
let after_first_remove = registry.len();
registry.remove(&beta.team_id);
// then
assert_eq!(after_create, 2);
assert_eq!(after_first_remove, 1);
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
#[test]
fn cron_list_all_disabled_returns_empty_for_enabled_only() {
// given
let registry = CronRegistry::new();
let first = registry.create("* * * * *", "Task 1", None);
let second = registry.create("0 * * * *", "Task 2", None);
registry
.disable(&first.cron_id)
.expect("disable should succeed");
registry
.disable(&second.cron_id)
.expect("disable should succeed");
// when
let enabled_only = registry.list(true);
let all_entries = registry.list(false);
// then
assert!(enabled_only.is_empty());
assert_eq!(all_entries.len(), 2);
}
#[test]
fn cron_create_without_description() {
// given
let registry = CronRegistry::new();
// when
let entry = registry.create("*/15 * * * *", "Check health", None);
// then
assert!(entry.cron_id.starts_with("cron_"));
assert_eq!(entry.description, None);
assert!(entry.enabled);
assert_eq!(entry.run_count, 0);
assert_eq!(entry.last_run_at, None);
}
#[test]
fn new_cron_registry_is_empty() {
// given
let registry = CronRegistry::new();
// when
let enabled_only = registry.list(true);
let all_entries = registry.list(false);
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(enabled_only.is_empty());
assert!(all_entries.is_empty());
}
#[test]
fn cron_record_run_updates_timestamp_and_counter() {
// given
let registry = CronRegistry::new();
let entry = registry.create("*/5 * * * *", "Recurring", None);
// when
registry
.record_run(&entry.cron_id)
.expect("first run should succeed");
registry
.record_run(&entry.cron_id)
.expect("second run should succeed");
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
// then
assert_eq!(fetched.run_count, 2);
assert!(fetched.last_run_at.is_some());
assert!(fetched.updated_at >= entry.updated_at);
}
#[test]
fn cron_disable_updates_timestamp() {
// given
let registry = CronRegistry::new();
let entry = registry.create("0 0 * * *", "Nightly", None);
// when
registry
.disable(&entry.cron_id)
.expect("disable should succeed");
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
// then
assert!(!fetched.enabled);
assert!(fetched.updated_at >= entry.updated_at);
}
}

View File

@@ -1,299 +0,0 @@
use std::path::{Path, PathBuf};
const TRUST_PROMPT_CUES: &[&str] = &[
"do you trust the files in this folder",
"trust the files in this folder",
"trust this folder",
"allow and continue",
"yes, proceed",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustPolicy {
AutoTrust,
RequireApproval,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustEvent {
TrustRequired { cwd: String },
TrustResolved { cwd: String, policy: TrustPolicy },
TrustDenied { cwd: String, reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct TrustConfig {
allowlisted: Vec<PathBuf>,
denied: Vec<PathBuf>,
}
impl TrustConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
self.allowlisted.push(path.into());
self
}
#[must_use]
pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
self.denied.push(path.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustDecision {
NotRequired,
Required {
policy: TrustPolicy,
events: Vec<TrustEvent>,
},
}
impl TrustDecision {
#[must_use]
pub fn policy(&self) -> Option<TrustPolicy> {
match self {
Self::NotRequired => None,
Self::Required { policy, .. } => Some(*policy),
}
}
#[must_use]
pub fn events(&self) -> &[TrustEvent] {
match self {
Self::NotRequired => &[],
Self::Required { events, .. } => events,
}
}
}
#[derive(Debug, Clone)]
pub struct TrustResolver {
config: TrustConfig,
}
impl TrustResolver {
#[must_use]
pub fn new(config: TrustConfig) -> Self {
Self { config }
}
#[must_use]
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
if !detect_trust_prompt(screen_text) {
return TrustDecision::NotRequired;
}
let mut events = vec![TrustEvent::TrustRequired {
cwd: cwd.to_owned(),
}];
if let Some(matched_root) = self
.config
.denied
.iter()
.find(|root| path_matches(cwd, root))
{
let reason = format!("cwd matches denied trust root: {}", matched_root.display());
events.push(TrustEvent::TrustDenied {
cwd: cwd.to_owned(),
reason,
});
return TrustDecision::Required {
policy: TrustPolicy::Deny,
events,
};
}
if self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
{
events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(),
policy: TrustPolicy::AutoTrust,
});
return TrustDecision::Required {
policy: TrustPolicy::AutoTrust,
events,
};
}
TrustDecision::Required {
policy: TrustPolicy::RequireApproval,
events,
}
}
#[must_use]
pub fn trusts(&self, cwd: &str) -> bool {
!self
.config
.denied
.iter()
.any(|root| path_matches(cwd, root))
&& self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
}
}
#[must_use]
pub fn detect_trust_prompt(screen_text: &str) -> bool {
let lowered = screen_text.to_ascii_lowercase();
TRUST_PROMPT_CUES
.iter()
.any(|needle| lowered.contains(needle))
}
#[must_use]
pub fn path_matches_trusted_root(cwd: &str, trusted_root: &str) -> bool {
path_matches(cwd, &normalize_path(Path::new(trusted_root)))
}
fn path_matches(candidate: &str, root: &Path) -> bool {
let candidate = normalize_path(Path::new(candidate));
let root = normalize_path(root);
candidate == root || candidate.starts_with(&root)
}
fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::{
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
TrustPolicy, TrustResolver,
};
#[test]
fn detects_known_trust_prompt_copy() {
// given
let screen_text = "Do you trust the files in this folder?\n1. Yes, proceed\n2. No";
// when
let detected = detect_trust_prompt(screen_text);
// then
assert!(detected);
}
#[test]
fn does_not_emit_events_when_prompt_is_absent() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
// then
assert_eq!(decision, TrustDecision::NotRequired);
assert_eq!(decision.events(), &[]);
assert_eq!(decision.policy(), None);
}
#[test]
fn auto_trusts_allowlisted_cwd_after_prompt_detection() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
assert_eq!(
decision.events(),
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-a".to_string(),
},
TrustEvent::TrustResolved {
cwd: "/tmp/worktrees/repo-a".to_string(),
policy: TrustPolicy::AutoTrust,
},
]
);
}
#[test]
fn requires_approval_for_unknown_cwd_after_prompt_detection() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve(
"/tmp/other/repo-b",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
assert_eq!(
decision.events(),
&[TrustEvent::TrustRequired {
cwd: "/tmp/other/repo-b".to_string(),
}]
);
}
#[test]
fn denied_root_takes_precedence_over_allowlist() {
// given
let resolver = TrustResolver::new(
TrustConfig::new()
.with_allowlisted("/tmp/worktrees")
.with_denied("/tmp/worktrees/repo-c"),
);
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-c",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::Deny));
assert_eq!(
decision.events(),
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-c".to_string(),
},
TrustEvent::TrustDenied {
cwd: "/tmp/worktrees/repo-c".to_string(),
reason: "cwd matches denied trust root: /tmp/worktrees/repo-c".to_string(),
},
]
);
}
#[test]
fn sibling_prefix_does_not_match_trusted_root() {
// given
let trusted_root = "/tmp/worktrees";
let sibling_path = "/tmp/worktrees-other/repo-d";
// when
let matched = path_matches_trusted_root(sibling_path, trusted_root);
// then
assert!(!matched);
}
}

View File

@@ -5,7 +5,6 @@ const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
const DEFAULT_CACHE_CREATION_COST_PER_MILLION: f64 = 18.75;
const DEFAULT_CACHE_READ_COST_PER_MILLION: f64 = 1.5;
/// Per-million-token pricing used for cost estimation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub input_cost_per_million: f64,
@@ -26,7 +25,6 @@ impl ModelPricing {
}
}
/// Token counters accumulated for a conversation turn or session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TokenUsage {
pub input_tokens: u32,
@@ -35,7 +33,6 @@ pub struct TokenUsage {
pub cache_read_input_tokens: u32,
}
/// Estimated dollar cost derived from a [`TokenUsage`] sample.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UsageCostEstimate {
pub input_cost_usd: f64,
@@ -54,7 +51,6 @@ impl UsageCostEstimate {
}
}
/// Returns pricing metadata for a known model alias or family.
#[must_use]
pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
let normalized = model.to_ascii_lowercase();
@@ -159,12 +155,10 @@ fn cost_for_tokens(tokens: u32, usd_per_million_tokens: f64) -> f64 {
}
#[must_use]
/// Formats a dollar-denominated value for CLI display.
pub fn format_usd(amount: f64) -> String {
format!("${amount:.4}")
}
/// Aggregates token usage across a running session.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageTracker {
latest_turn: TokenUsage,
@@ -292,19 +286,21 @@ mod tests {
#[test]
fn reconstructs_usage_from_session_messages() {
let mut session = Session::new();
session.messages = vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "done".to_string(),
let session = Session {
version: 1,
messages: vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "done".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 2,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
}),
}],
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 2,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
}),
}];
};
let tracker = UsageTracker::from_session(&session);
assert_eq!(tracker.turns(), 1);

File diff suppressed because it is too large Load Diff

View File

@@ -1,385 +0,0 @@
//! Integration tests for cross-module wiring.
//!
//! These tests verify that adjacent modules in the runtime crate actually
//! connect correctly — catching wiring gaps that unit tests miss.
use std::time::Duration;
use runtime::green_contract::{GreenContract, GreenContractOutcome, GreenLevel};
use runtime::{
apply_policy, BranchFreshness, DiffScope, LaneBlocker, LaneContext, PolicyAction,
PolicyCondition, PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, StaleBranchAction,
StaleBranchPolicy,
};
/// stale_branch + policy_engine integration:
/// When a branch is detected stale, does it correctly flow through
/// PolicyCondition::StaleBranch to generate the expected action?
#[test]
fn stale_branch_detection_flows_into_policy_engine() {
// given — a stale branch context (2 hours behind main, threshold is 1 hour)
let stale_context = LaneContext::new(
"stale-lane",
0,
Duration::from_secs(2 * 60 * 60), // 2 hours stale
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"stale-merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
// when
let actions = engine.evaluate(&stale_context);
// then
assert_eq!(actions, vec![PolicyAction::MergeForward]);
}
/// stale_branch + policy_engine: Fresh branch does NOT trigger stale rules
#[test]
fn fresh_branch_does_not_trigger_stale_policy() {
let fresh_context = LaneContext::new(
"fresh-lane",
0,
Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"stale-merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
let actions = engine.evaluate(&fresh_context);
assert!(actions.is_empty());
}
/// green_contract + policy_engine integration:
/// A lane that meets its green contract should be mergeable
#[test]
fn green_contract_satisfied_allows_merge() {
let contract = GreenContract::new(GreenLevel::Workspace);
let satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
assert!(satisfied);
let exceeded = contract.is_satisfied_by(GreenLevel::MergeReady);
assert!(exceeded);
let insufficient = contract.is_satisfied_by(GreenLevel::Package);
assert!(!insufficient);
}
/// green_contract + policy_engine:
/// Lane with green level below contract requirement gets blocked
#[test]
fn green_contract_unsatisfied_blocks_merge() {
let context = LaneContext::new(
"partial-green-lane",
1, // GreenLevel::Package as u8
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// This is a conceptual test — we need a way to express "requires workspace green"
// Currently LaneContext has raw green_level: u8, not a contract
// For now we just verify the policy condition works
let engine = PolicyEngine::new(vec![PolicyRule::new(
"workspace-green-required",
PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
PolicyAction::MergeToDev,
10,
)]);
let actions = engine.evaluate(&context);
assert!(actions.is_empty()); // level 1 < 3, so no merge
}
/// reconciliation + policy_engine integration:
/// A reconciled lane should be handled by reconcile rules, not generic closeout
#[test]
fn reconciled_lane_matches_reconcile_condition() {
let context = LaneContext::reconciled("reconciled-lane");
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"reconcile-first",
PolicyCondition::LaneReconciled,
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
5,
),
PolicyRule::new(
"generic-closeout",
PolicyCondition::LaneCompleted,
PolicyAction::CloseoutLane,
30,
),
]);
let actions = engine.evaluate(&context);
// Both rules fire — reconcile (priority 5) first, then closeout (priority 30)
assert_eq!(
actions,
vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
]
);
}
/// stale_branch module: apply_policy generates correct actions
#[test]
fn stale_branch_apply_policy_produces_rebase_action() {
let stale = BranchFreshness::Stale {
commits_behind: 5,
missing_fixes: vec!["fix-123".to_string()],
};
let action = apply_policy(&stale, StaleBranchPolicy::AutoRebase);
assert_eq!(action, StaleBranchAction::Rebase);
}
#[test]
fn stale_branch_apply_policy_produces_merge_forward_action() {
let stale = BranchFreshness::Stale {
commits_behind: 3,
missing_fixes: vec![],
};
let action = apply_policy(&stale, StaleBranchPolicy::AutoMergeForward);
assert_eq!(action, StaleBranchAction::MergeForward);
}
#[test]
fn stale_branch_apply_policy_warn_only() {
let stale = BranchFreshness::Stale {
commits_behind: 2,
missing_fixes: vec!["fix-456".to_string()],
};
let action = apply_policy(&stale, StaleBranchPolicy::WarnOnly);
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("2 commit(s) behind main"));
assert!(message.contains("fix-456"));
}
_ => panic!("expected Warn action, got {:?}", action),
}
}
#[test]
fn stale_branch_fresh_produces_noop() {
let fresh = BranchFreshness::Fresh;
let action = apply_policy(&fresh, StaleBranchPolicy::AutoRebase);
assert_eq!(action, StaleBranchAction::Noop);
}
/// Combined flow: stale detection + policy + action
#[test]
fn end_to_end_stale_lane_gets_merge_forward_action() {
// Simulating what a harness would do:
// 1. Detect branch freshness
// 2. Build lane context from freshness + other signals
// 3. Run policy engine
// 4. Return actions
// given: detected stale state
let _freshness = BranchFreshness::Stale {
commits_behind: 5,
missing_fixes: vec!["fix-123".to_string()],
};
// when: build context and evaluate policy
let context = LaneContext::new(
"lane-9411",
3, // Workspace green
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let engine = PolicyEngine::new(vec![
// Priority 5: Check if stale first
PolicyRule::new(
"auto-merge-forward-if-stale-and-approved",
PolicyCondition::And(vec![
PolicyCondition::StaleBranch,
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeForward,
5,
),
// Priority 10: Normal stale handling
PolicyRule::new(
"stale-warning",
PolicyCondition::StaleBranch,
PolicyAction::Notify {
channel: "#build-status".to_string(),
},
10,
),
]);
let actions = engine.evaluate(&context);
// then: both rules should fire (stale + approved matches both)
assert_eq!(
actions,
vec![
PolicyAction::MergeForward,
PolicyAction::Notify {
channel: "#build-status".to_string(),
},
]
);
}
/// Fresh branch with approved review should merge (not stale-blocked)
#[test]
fn fresh_approved_lane_gets_merge_action() {
let context = LaneContext::new(
"fresh-approved-lane",
3, // Workspace green
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-if-green-approved-not-stale",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 3 },
PolicyCondition::ReviewPassed,
// NOT PolicyCondition::StaleBranch — fresh lanes bypass this
]),
PolicyAction::MergeToDev,
5,
)]);
let actions = engine.evaluate(&context);
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
}
/// worker_boot + recovery_recipes + policy_engine integration:
/// When a session completes with a provider failure, does the worker
/// status transition trigger the correct recovery recipe, and does
/// the resulting recovery state feed into policy decisions?
#[test]
fn worker_provider_failure_flows_through_recovery_to_policy() {
use runtime::recovery_recipes::{
attempt_recovery, FailureScenario, RecoveryContext, RecoveryResult, RecoveryStep,
};
use runtime::worker_boot::{WorkerFailureKind, WorkerRegistry, WorkerStatus};
// given — a worker that encounters a provider failure during session completion
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-recovery-test", &[], true);
// Worker reaches ready state
registry
.observe(&worker.worker_id, "Ready for your input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run analysis"))
.expect("prompt send should succeed");
// Session completes with provider failure (finish="unknown", tokens=0)
let failed_worker = registry
.observe_completion(&worker.worker_id, "unknown", 0)
.expect("completion observe should succeed");
assert_eq!(failed_worker.status, WorkerStatus::Failed);
let failure = failed_worker
.last_error
.expect("worker should have recorded error");
assert_eq!(failure.kind, WorkerFailureKind::Provider);
// Bridge: WorkerFailureKind -> FailureScenario
let scenario = FailureScenario::from_worker_failure_kind(failure.kind);
assert_eq!(scenario, FailureScenario::ProviderFailure);
// Recovery recipe lookup and execution
let mut ctx = RecoveryContext::new();
let result = attempt_recovery(&scenario, &mut ctx);
// then — recovery should recommend RestartWorker step
assert!(
matches!(result, RecoveryResult::Recovered { steps_taken: 1 }),
"provider failure should recover via single RestartWorker step, got: {result:?}"
);
assert!(
ctx.events().iter().any(|e| {
matches!(
e,
runtime::recovery_recipes::RecoveryEvent::RecoveryAttempted {
result: RecoveryResult::Recovered { steps_taken: 1 },
..
}
)
}),
"recovery should emit structured attempt event"
);
// Policy integration: recovery success + green status = merge-ready
// (Simulating the policy check that would happen after successful recovery)
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
let green_level = 3; // Workspace green
let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
let post_recovery_context = LaneContext::new(
"recovered-lane",
green_level,
not_stale,
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let policy_engine = PolicyEngine::new(vec![
// Rule: if recovered from failure + green + approved -> merge
PolicyRule::new(
"merge-after-successful-recovery",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 3 },
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeToDev,
10,
),
]);
// Recovery success is a pre-condition; policy evaluates post-recovery context
assert!(
recovery_success,
"recovery must succeed for lane to proceed"
);
let actions = policy_engine.evaluate(&post_recovery_context);
assert_eq!(
actions,
vec![PolicyAction::MergeToDev],
"post-recovery green+approved lane should be merge-ready"
);
}

View File

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

View File

@@ -18,16 +18,10 @@ pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" }
[lints]
workspace = true
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }

View File

@@ -44,11 +44,6 @@ pub enum SlashCommand {
Help,
Status,
Compact,
Model { model: Option<String> },
Permissions { mode: Option<String> },
Config { section: Option<String> },
Memory,
Clear { confirm: bool },
Unknown(String),
}
@@ -60,25 +55,15 @@ impl SlashCommand {
return None;
}
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
let command = parts.next().unwrap_or_default();
let command = trimmed
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or_default();
Some(match command {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
"permissions" => Self::Permissions {
mode: parts.next().map(ToOwned::to_owned),
},
"config" => Self::Config {
section: parts.next().map(ToOwned::to_owned),
},
"memory" => Self::Memory,
"clear" => Self::Clear {
confirm: parts.next() == Some("--confirm"),
},
other => Self::Unknown(other.to_string()),
})
}
@@ -102,26 +87,6 @@ const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
command: SlashCommand::Compact,
summary: "Compact local session history",
},
SlashCommandHandler {
command: SlashCommand::Model { model: None },
summary: "Show or switch the active model",
},
SlashCommandHandler {
command: SlashCommand::Permissions { mode: None },
summary: "Show or switch the active permission mode",
},
SlashCommandHandler {
command: SlashCommand::Config { section: None },
summary: "Inspect current config path or section",
},
SlashCommandHandler {
command: SlashCommand::Memory,
summary: "Inspect loaded memory/instruction files",
},
SlashCommandHandler {
command: SlashCommand::Clear { confirm: false },
summary: "Start a fresh local session",
},
];
pub struct CliApp {
@@ -193,11 +158,6 @@ impl CliApp {
SlashCommand::Help => Self::handle_help(out),
SlashCommand::Status => self.handle_status(out),
SlashCommand::Compact => self.handle_compact(out),
SlashCommand::Model { model } => self.handle_model(model.as_deref(), out),
SlashCommand::Permissions { mode } => self.handle_permissions(mode.as_deref(), out),
SlashCommand::Config { section } => self.handle_config(section.as_deref(), out),
SlashCommand::Memory => self.handle_memory(out),
SlashCommand::Clear { confirm } => self.handle_clear(confirm, out),
SlashCommand::Unknown(name) => {
writeln!(out, "Unknown slash command: /{name}")?;
Ok(CommandResult::Continue)
@@ -212,11 +172,6 @@ impl CliApp {
SlashCommand::Help => "/help",
SlashCommand::Status => "/status",
SlashCommand::Compact => "/compact",
SlashCommand::Model { .. } => "/model [model]",
SlashCommand::Permissions { .. } => "/permissions [mode]",
SlashCommand::Config { .. } => "/config [section]",
SlashCommand::Memory => "/memory",
SlashCommand::Clear { .. } => "/clear [--confirm]",
SlashCommand::Unknown(_) => continue,
};
writeln!(out, " {name:<9} {}", handler.summary)?;
@@ -254,102 +209,6 @@ impl CliApp {
Ok(CommandResult::Continue)
}
fn handle_model(
&mut self,
model: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match model {
Some(model) => {
self.config.model = model.to_string();
self.state.last_model = model.to_string();
writeln!(out, "Active model set to {model}")?;
}
None => {
writeln!(out, "Active model: {}", self.config.model)?;
}
}
Ok(CommandResult::Continue)
}
fn handle_permissions(
&mut self,
mode: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match mode {
None => writeln!(out, "Permission mode: {:?}", self.config.permission_mode)?,
Some("read-only") => {
self.config.permission_mode = PermissionMode::ReadOnly;
writeln!(out, "Permission mode set to read-only")?;
}
Some("workspace-write") => {
self.config.permission_mode = PermissionMode::WorkspaceWrite;
writeln!(out, "Permission mode set to workspace-write")?;
}
Some("danger-full-access") => {
self.config.permission_mode = PermissionMode::DangerFullAccess;
writeln!(out, "Permission mode set to danger-full-access")?;
}
Some(other) => {
writeln!(out, "Unknown permission mode: {other}")?;
}
}
Ok(CommandResult::Continue)
}
fn handle_config(
&mut self,
section: Option<&str>,
out: &mut impl Write,
) -> io::Result<CommandResult> {
match section {
None => writeln!(
out,
"Config path: {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?,
Some(section) => writeln!(
out,
"Config section `{section}` is not fully implemented yet; current config path is {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?,
}
Ok(CommandResult::Continue)
}
fn handle_memory(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
writeln!(
out,
"Loaded memory/config file: {}",
self.config
.config
.as_ref()
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
)?;
Ok(CommandResult::Continue)
}
fn handle_clear(&mut self, confirm: bool, out: &mut impl Write) -> io::Result<CommandResult> {
if !confirm {
writeln!(out, "Refusing to clear without confirmation. Re-run as /clear --confirm")?;
return Ok(CommandResult::Continue);
}
self.state.turns = 0;
self.state.compacted_messages = 0;
self.state.last_usage = UsageSummary::default();
self.conversation_history.clear();
writeln!(out, "Started a fresh local session.")?;
Ok(CommandResult::Continue)
}
fn handle_stream_event(
renderer: &TerminalRenderer,
event: StreamEvent,
@@ -510,29 +369,6 @@ mod tests {
SlashCommand::parse("/compact now"),
Some(SlashCommand::Compact)
);
assert_eq!(
SlashCommand::parse("/model claude-sonnet"),
Some(SlashCommand::Model {
model: Some("claude-sonnet".into()),
})
);
assert_eq!(
SlashCommand::parse("/permissions workspace-write"),
Some(SlashCommand::Permissions {
mode: Some("workspace-write".into()),
})
);
assert_eq!(
SlashCommand::parse("/config hooks"),
Some(SlashCommand::Config {
section: Some("hooks".into()),
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
}
#[test]
@@ -544,11 +380,6 @@ mod tests {
assert!(output.contains("/help"));
assert!(output.contains("/status"));
assert!(output.contains("/compact"));
assert!(output.contains("/model [model]"));
assert!(output.contains("/permissions [mode]"));
assert!(output.contains("/config [section]"));
assert!(output.contains("/memory"));
assert!(output.contains("/clear [--confirm]"));
}
#[test]

View File

@@ -1,6 +1,5 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Write};
use rustyline::completion::{Completer, Pair};
@@ -28,7 +27,7 @@ struct SlashCommandHelper {
impl SlashCommandHelper {
fn new(completions: Vec<String>) -> Self {
Self {
completions: normalize_completions(completions),
completions,
current_line: RefCell::new(String::new()),
}
}
@@ -46,10 +45,6 @@ impl SlashCommandHelper {
current.clear();
current.push_str(line);
}
fn set_completions(&mut self, completions: Vec<String>) {
self.completions = normalize_completions(completions);
}
}
impl Completer for SlashCommandHelper {
@@ -131,12 +126,6 @@ impl LineEditor {
let _ = self.editor.add_history_entry(entry);
}
pub fn set_completions(&mut self, completions: Vec<String>) {
if let Some(helper) = self.editor.helper_mut() {
helper.set_completions(completions);
}
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback();
@@ -203,22 +192,13 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
}
let prefix = &line[..pos];
if !prefix.starts_with('/') {
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
}
Some(prefix)
}
fn normalize_completions(completions: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
completions
.into_iter()
.filter(|candidate| candidate.starts_with('/'))
.filter(|candidate| seen.insert(candidate.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
@@ -228,13 +208,9 @@ mod tests {
use rustyline::Context;
#[test]
fn extracts_terminal_slash_command_prefixes_with_arguments() {
fn extracts_only_terminal_slash_command_prefixes() {
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
assert_eq!(slash_command_prefix("/help me", 8), Some("/help me"));
assert_eq!(
slash_command_prefix("/session switch ses", 19),
Some("/session switch ses")
);
assert_eq!(slash_command_prefix("/help me", 5), None);
assert_eq!(slash_command_prefix("hello", 5), None);
assert_eq!(slash_command_prefix("/help", 2), None);
}
@@ -262,30 +238,6 @@ mod tests {
);
}
#[test]
fn completes_matching_slash_command_arguments() {
let helper = SlashCommandHelper::new(vec![
"/model".to_string(),
"/model opus".to_string(),
"/model sonnet".to_string(),
"/session switch alpha".to_string(),
]);
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let (start, matches) = helper
.complete("/model o", 8, &ctx)
.expect("completion should work");
assert_eq!(start, 0);
assert_eq!(
matches
.into_iter()
.map(|candidate| candidate.replacement)
.collect::<Vec<_>>(),
vec!["/model opus".to_string()]
);
}
#[test]
fn ignores_non_slash_command_completion_requests() {
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
@@ -314,17 +266,4 @@ mod tests {
assert_eq!(editor.editor.history().len(), 1);
}
#[test]
fn set_completions_replaces_and_normalizes_candidates() {
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
editor.set_completions(vec![
"/model opus".to_string(),
"/model opus".to_string(),
"status".to_string(),
]);
let helper = editor.editor.helper().expect("helper should exist");
assert_eq!(helper.completions, vec!["/model opus".to_string()]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -286,7 +286,7 @@ impl TerminalRenderer {
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
Self::start_heading(state, level as u8, output);
self.start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
@@ -426,7 +426,8 @@ impl TerminalRenderer {
}
}
fn start_heading(state: &mut RenderState, level: u8, output: &mut String) {
#[allow(clippy::unused_self)]
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
state.heading_level = Some(level);
if !output.is_empty() {
output.push('\n');

View File

@@ -1,200 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn status_command_applies_model_and_permission_mode_flags() {
// given
let temp_dir = unique_temp_dir("status-flags");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
// when
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.args([
"--model",
"sonnet",
"--permission-mode",
"read-only",
"status",
])
.output()
.expect("claw should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model claude-sonnet-4-6"));
assert!(stdout.contains("Permission mode read-only"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn resume_flag_loads_a_saved_session_and_dispatches_status() {
// given
let temp_dir = unique_temp_dir("resume-status");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = write_session(&temp_dir, "resume-status");
// when
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.args([
"--resume",
session_path.to_str().expect("utf8 path"),
"/status",
])
.output()
.expect("claw should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Messages 1"));
assert!(stdout.contains("Session "));
assert!(stdout.contains(session_path.to_str().expect("utf8 path")));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
// given
let temp_dir = unique_temp_dir("slash-dispatch");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
// when
let help_output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.arg("/help")
.output()
.expect("claw should launch");
let unknown_output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.arg("/zstats")
.output()
.expect("claw should launch");
// then
assert_success(&help_output);
let help_stdout = String::from_utf8(help_output.stdout).expect("stdout should be utf8");
assert!(help_stdout.contains("Interactive slash commands:"));
assert!(help_stdout.contains("/status"));
assert!(
!unknown_output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&unknown_output.stdout),
String::from_utf8_lossy(&unknown_output.stderr)
);
let stderr = String::from_utf8(unknown_output.stderr).expect("stderr should be utf8");
assert!(stderr.contains("unknown slash command outside the REPL: /zstats"));
assert!(stderr.contains("Did you mean"));
assert!(stderr.contains("/status"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn config_command_loads_defaults_from_standard_config_locations() {
// given
let temp_dir = unique_temp_dir("config-defaults");
let config_home = temp_dir.join("home").join(".claw");
fs::create_dir_all(temp_dir.join(".claw")).expect("project config dir should exist");
fs::create_dir_all(&config_home).expect("home config dir should exist");
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
.expect("write user settings");
fs::write(temp_dir.join(".claw.json"), r#"{"model":"sonnet"}"#)
.expect("write project settings");
fs::write(
temp_dir.join(".claw").join("settings.local.json"),
r#"{"model":"opus"}"#,
)
.expect("write local settings");
let session_path = write_session(&temp_dir, "config-defaults");
// when
let output = command_in(&temp_dir)
.env("CLAW_CONFIG_HOME", &config_home)
.args([
"--resume",
session_path.to_str().expect("utf8 path"),
"/config",
"model",
])
.output()
.expect("claw should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Config"));
assert!(stdout.contains("Loaded files 3"));
assert!(stdout.contains("Merged section: model"));
assert!(stdout.contains("opus"));
assert!(stdout.contains(
config_home
.join("settings.json")
.to_str()
.expect("utf8 path")
));
assert!(stdout.contains(temp_dir.join(".claw.json").to_str().expect("utf8 path")));
assert!(stdout.contains(
temp_dir
.join(".claw")
.join("settings.local.json")
.to_str()
.expect("utf8 path")
));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
fn command_in(cwd: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(cwd);
command
}
fn write_session(root: &Path, label: &str) -> PathBuf {
let session_path = root.join(format!("{label}.jsonl"));
let mut session = Session::new();
session
.push_user_text(format!("session fixture for {label}"))
.expect("session write should succeed");
session
.save_to_path(&session_path)
.expect("session should persist");
session_path
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-{label}-{}-{millis}-{counter}",
std::process::id()
))
}

View File

@@ -1,877 +0,0 @@
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::{json, Value};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
#[allow(clippy::too_many_lines)]
fn clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios() {
let manifest_entries = load_scenario_manifest();
let manifest = manifest_entries
.iter()
.cloned()
.map(|entry| (entry.name.clone(), entry))
.collect::<BTreeMap<_, _>>();
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let cases = [
ScenarioCase {
name: "streaming_text",
permission_mode: "read-only",
allowed_tools: None,
stdin: None,
prepare: prepare_noop,
assert: assert_streaming_text,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "read_file_roundtrip",
permission_mode: "read-only",
allowed_tools: Some("read_file"),
stdin: None,
prepare: prepare_read_fixture,
assert: assert_read_file_roundtrip,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "grep_chunk_assembly",
permission_mode: "read-only",
allowed_tools: Some("grep_search"),
stdin: None,
prepare: prepare_grep_fixture,
assert: assert_grep_chunk_assembly,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "write_file_allowed",
permission_mode: "workspace-write",
allowed_tools: Some("write_file"),
stdin: None,
prepare: prepare_noop,
assert: assert_write_file_allowed,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "write_file_denied",
permission_mode: "read-only",
allowed_tools: Some("write_file"),
stdin: None,
prepare: prepare_noop,
assert: assert_write_file_denied,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "multi_tool_turn_roundtrip",
permission_mode: "read-only",
allowed_tools: Some("read_file,grep_search"),
stdin: None,
prepare: prepare_multi_tool_fixture,
assert: assert_multi_tool_turn_roundtrip,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "bash_stdout_roundtrip",
permission_mode: "danger-full-access",
allowed_tools: Some("bash"),
stdin: None,
prepare: prepare_noop,
assert: assert_bash_stdout_roundtrip,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "bash_permission_prompt_approved",
permission_mode: "workspace-write",
allowed_tools: Some("bash"),
stdin: Some("y\n"),
prepare: prepare_noop,
assert: assert_bash_permission_prompt_approved,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "bash_permission_prompt_denied",
permission_mode: "workspace-write",
allowed_tools: Some("bash"),
stdin: Some("n\n"),
prepare: prepare_noop,
assert: assert_bash_permission_prompt_denied,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "plugin_tool_roundtrip",
permission_mode: "workspace-write",
allowed_tools: None,
stdin: None,
prepare: prepare_plugin_fixture,
assert: assert_plugin_tool_roundtrip,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "auto_compact_triggered",
permission_mode: "read-only",
allowed_tools: None,
stdin: None,
prepare: prepare_noop,
assert: assert_auto_compact_triggered,
extra_env: None,
resume_session: None,
},
ScenarioCase {
name: "token_cost_reporting",
permission_mode: "read-only",
allowed_tools: None,
stdin: None,
prepare: prepare_noop,
assert: assert_token_cost_reporting,
extra_env: None,
resume_session: None,
},
];
let case_names = cases.iter().map(|case| case.name).collect::<Vec<_>>();
let manifest_names = manifest_entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
case_names, manifest_names,
"manifest and harness cases must stay aligned"
);
let mut scenario_reports = Vec::new();
for case in cases {
let workspace = HarnessWorkspace::new(unique_temp_dir(case.name));
workspace.create().expect("workspace should exist");
(case.prepare)(&workspace);
let run = run_case(case, &workspace, &base_url);
(case.assert)(&workspace, &run);
let manifest_entry = manifest
.get(case.name)
.unwrap_or_else(|| panic!("missing manifest entry for {}", case.name));
scenario_reports.push(build_scenario_report(
case.name,
manifest_entry,
&run.response,
));
fs::remove_dir_all(&workspace.root).expect("workspace cleanup should succeed");
}
let captured = runtime.block_on(server.captured_requests());
assert_eq!(
captured.len(),
21,
"twelve scenarios should produce twenty-one requests"
);
assert!(captured
.iter()
.all(|request| request.path == "/v1/messages"));
assert!(captured.iter().all(|request| request.stream));
let scenarios = captured
.iter()
.map(|request| request.scenario.as_str())
.collect::<Vec<_>>();
assert_eq!(
scenarios,
vec![
"streaming_text",
"read_file_roundtrip",
"read_file_roundtrip",
"grep_chunk_assembly",
"grep_chunk_assembly",
"write_file_allowed",
"write_file_allowed",
"write_file_denied",
"write_file_denied",
"multi_tool_turn_roundtrip",
"multi_tool_turn_roundtrip",
"bash_stdout_roundtrip",
"bash_stdout_roundtrip",
"bash_permission_prompt_approved",
"bash_permission_prompt_approved",
"bash_permission_prompt_denied",
"bash_permission_prompt_denied",
"plugin_tool_roundtrip",
"plugin_tool_roundtrip",
"auto_compact_triggered",
"token_cost_reporting",
]
);
let mut request_counts = BTreeMap::new();
for request in &captured {
*request_counts
.entry(request.scenario.as_str())
.or_insert(0_usize) += 1;
}
for report in &mut scenario_reports {
report.request_count = *request_counts
.get(report.name.as_str())
.unwrap_or_else(|| panic!("missing request count for {}", report.name));
}
maybe_write_report(&scenario_reports);
}
#[derive(Clone, Copy)]
struct ScenarioCase {
name: &'static str,
permission_mode: &'static str,
allowed_tools: Option<&'static str>,
stdin: Option<&'static str>,
prepare: fn(&HarnessWorkspace),
assert: fn(&HarnessWorkspace, &ScenarioRun),
extra_env: Option<(&'static str, &'static str)>,
resume_session: Option<&'static str>,
}
struct HarnessWorkspace {
root: PathBuf,
config_home: PathBuf,
home: PathBuf,
}
impl HarnessWorkspace {
fn new(root: PathBuf) -> Self {
Self {
config_home: root.join("config-home"),
home: root.join("home"),
root,
}
}
fn create(&self) -> std::io::Result<()> {
fs::create_dir_all(&self.root)?;
fs::create_dir_all(&self.config_home)?;
fs::create_dir_all(&self.home)?;
Ok(())
}
}
struct ScenarioRun {
response: Value,
stdout: String,
}
#[derive(Debug, Clone)]
struct ScenarioManifestEntry {
name: String,
category: String,
description: String,
parity_refs: Vec<String>,
}
#[derive(Debug)]
struct ScenarioReport {
name: String,
category: String,
description: String,
parity_refs: Vec<String>,
iterations: u64,
request_count: usize,
tool_uses: Vec<String>,
tool_error_count: usize,
final_message: String,
}
fn run_case(case: ScenarioCase, workspace: &HarnessWorkspace, base_url: &str) -> ScenarioRun {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command
.current_dir(&workspace.root)
.env_clear()
.env("ANTHROPIC_API_KEY", "test-parity-key")
.env("ANTHROPIC_BASE_URL", base_url)
.env("CLAW_CONFIG_HOME", &workspace.config_home)
.env("HOME", &workspace.home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.args([
"--model",
"sonnet",
"--permission-mode",
case.permission_mode,
"--output-format=json",
]);
if let Some(allowed_tools) = case.allowed_tools {
command.args(["--allowedTools", allowed_tools]);
}
if let Some((key, value)) = case.extra_env {
command.env(key, value);
}
if let Some(session_id) = case.resume_session {
command.args(["--resume", session_id]);
}
let prompt = format!("{SCENARIO_PREFIX}{}", case.name);
command.arg(prompt);
let output = if let Some(stdin) = case.stdin {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("claw should launch");
child
.stdin
.as_mut()
.expect("stdin should be piped")
.write_all(stdin.as_bytes())
.expect("stdin should write");
child.wait_with_output().expect("claw should finish")
} else {
command.output().expect("claw should launch")
};
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
ScenarioRun {
response: parse_json_output(&stdout),
stdout,
}
}
#[allow(dead_code)]
fn prepare_auto_compact_fixture(workspace: &HarnessWorkspace) {
let sessions_dir = workspace.root.join(".claw").join("sessions");
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
// Write a pre-seeded session with 6 messages so auto-compact can remove them
let session_id = "parity-auto-compact-seed";
let session_jsonl = r#"{"type":"session_meta","version":3,"session_id":"parity-auto-compact-seed","created_at_ms":1743724800000,"updated_at_ms":1743724800000}
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step one of the parity scenario"}]}}
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step one"}]}}
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step two of the parity scenario"}]}}
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step two"}]}}
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step three of the parity scenario"}]}}
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step three"}]}}
"#;
fs::write(
sessions_dir.join(format!("{session_id}.jsonl")),
session_jsonl,
)
.expect("pre-seeded session should write");
}
fn prepare_noop(_: &HarnessWorkspace) {}
fn prepare_read_fixture(workspace: &HarnessWorkspace) {
fs::write(workspace.root.join("fixture.txt"), "alpha parity line\n")
.expect("fixture should write");
}
fn prepare_grep_fixture(workspace: &HarnessWorkspace) {
fs::write(
workspace.root.join("fixture.txt"),
"alpha parity line\nbeta line\ngamma parity line\n",
)
.expect("grep fixture should write");
}
fn prepare_multi_tool_fixture(workspace: &HarnessWorkspace) {
fs::write(
workspace.root.join("fixture.txt"),
"alpha parity line\nbeta line\ngamma parity line\n",
)
.expect("multi tool fixture should write");
}
fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
let plugin_root = workspace
.root
.join("external-plugins")
.join("parity-plugin");
let tool_dir = plugin_root.join("tools");
let manifest_dir = plugin_root.join(".claude-plugin");
fs::create_dir_all(&tool_dir).expect("plugin tools dir");
fs::create_dir_all(&manifest_dir).expect("plugin manifest dir");
let script_path = tool_dir.join("echo-json.sh");
fs::write(
&script_path,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("plugin script should write");
let mut permissions = fs::metadata(&script_path)
.expect("plugin script metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
fs::write(
manifest_dir.join("plugin.json"),
r#"{
"name": "parity-plugin",
"version": "1.0.0",
"description": "mock parity plugin",
"tools": [
{
"name": "plugin_echo",
"description": "Echo JSON input",
"inputSchema": {
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"],
"additionalProperties": false
},
"command": "./tools/echo-json.sh",
"requiredPermission": "workspace-write"
}
]
}"#,
)
.expect("plugin manifest should write");
fs::write(
workspace.config_home.join("settings.json"),
json!({
"enabledPlugins": {
"parity-plugin@external": true
},
"plugins": {
"externalDirectories": [plugin_root.parent().expect("plugin parent").display().to_string()]
}
})
.to_string(),
)
.expect("plugin settings should write");
}
fn assert_streaming_text(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(
run.response["message"],
Value::String("Mock streaming says hello from the parity harness.".to_string())
);
assert_eq!(run.response["iterations"], Value::from(1));
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
assert_eq!(run.response["tool_results"], Value::Array(Vec::new()));
}
fn assert_read_file_roundtrip(workspace: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("read_file".to_string())
);
assert_eq!(
run.response["tool_uses"][0]["input"],
Value::String(r#"{"path":"fixture.txt"}"#.to_string())
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("alpha parity line"));
let output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
assert!(output.contains(&workspace.root.join("fixture.txt").display().to_string()));
assert!(output.contains("alpha parity line"));
}
fn assert_grep_chunk_assembly(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("grep_search".to_string())
);
assert_eq!(
run.response["tool_uses"][0]["input"],
Value::String(
r#"{"pattern":"parity","path":"fixture.txt","output_mode":"count"}"#.to_string()
)
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("2 occurrences"));
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(false)
);
}
fn assert_write_file_allowed(workspace: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("write_file".to_string())
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("generated/output.txt"));
let generated = workspace.root.join("generated").join("output.txt");
let contents = fs::read_to_string(&generated).expect("generated file should exist");
assert_eq!(contents, "created by mock service\n");
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(false)
);
}
fn assert_write_file_denied(workspace: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("write_file".to_string())
);
let tool_output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
assert!(tool_output.contains("requires workspace-write permission"));
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(true)
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("denied as expected"));
assert!(!workspace.root.join("generated").join("denied.txt").exists());
}
fn assert_multi_tool_turn_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
let tool_uses = run.response["tool_uses"]
.as_array()
.expect("tool uses array");
assert_eq!(
tool_uses.len(),
2,
"expected two tool uses in a single turn"
);
assert_eq!(tool_uses[0]["name"], Value::String("read_file".to_string()));
assert_eq!(
tool_uses[1]["name"],
Value::String("grep_search".to_string())
);
let tool_results = run.response["tool_results"]
.as_array()
.expect("tool results array");
assert_eq!(
tool_results.len(),
2,
"expected two tool results in a single turn"
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("alpha parity line"));
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("2 occurrences"));
}
fn assert_bash_stdout_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("bash".to_string())
);
let tool_output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
assert_eq!(
parsed["stdout"],
Value::String("alpha from bash".to_string())
);
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(false)
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("alpha from bash"));
}
fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRun) {
assert!(run.stdout.contains("Permission approval required"));
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(false)
);
let tool_output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
assert_eq!(
parsed["stdout"],
Value::String("approved via prompt".to_string())
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("approved and executed"));
}
fn assert_bash_permission_prompt_denied(_: &HarnessWorkspace, run: &ScenarioRun) {
assert!(run.stdout.contains("Permission approval required"));
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
assert_eq!(run.response["iterations"], Value::from(2));
let tool_output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
assert!(tool_output.contains("denied by user approval prompt"));
assert_eq!(
run.response["tool_results"][0]["is_error"],
Value::Bool(true)
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("denied as expected"));
}
fn assert_plugin_tool_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(2));
assert_eq!(
run.response["tool_uses"][0]["name"],
Value::String("plugin_echo".to_string())
);
let tool_output = run.response["tool_results"][0]["output"]
.as_str()
.expect("tool output");
let parsed: Value = serde_json::from_str(tool_output).expect("plugin output json");
assert_eq!(
parsed["plugin"],
Value::String("parity-plugin@external".to_string())
);
assert_eq!(parsed["tool"], Value::String("plugin_echo".to_string()));
assert_eq!(
parsed["input"]["message"],
Value::String("hello from plugin parity".to_string())
);
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("hello from plugin parity"));
}
fn assert_auto_compact_triggered(_: &HarnessWorkspace, run: &ScenarioRun) {
// Validates that the auto_compaction field is present in JSON output (format parity).
// Trigger behavior is covered by conversation::tests::auto_compacts_when_cumulative_input_threshold_is_crossed.
assert_eq!(run.response["iterations"], Value::from(1));
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
assert!(
run.response["message"]
.as_str()
.expect("message text")
.contains("auto compact parity complete."),
"expected auto compact message in response"
);
// auto_compaction key must be present in JSON (may be null for below-threshold sessions)
assert!(
run.response
.as_object()
.expect("response object")
.contains_key("auto_compaction"),
"auto_compaction key must be present in JSON output"
);
// Verify input_tokens field reflects the large mock token counts
let input_tokens = run.response["usage"]["input_tokens"]
.as_u64()
.expect("input_tokens should be present");
assert!(
input_tokens >= 50_000,
"input_tokens should reflect mock service value (got {input_tokens})"
);
}
fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
assert_eq!(run.response["iterations"], Value::from(1));
assert!(run.response["message"]
.as_str()
.expect("message text")
.contains("token cost reporting parity complete."),);
let usage = &run.response["usage"];
assert!(
usage["input_tokens"].as_u64().unwrap_or(0) > 0,
"input_tokens should be non-zero"
);
assert!(
usage["output_tokens"].as_u64().unwrap_or(0) > 0,
"output_tokens should be non-zero"
);
assert!(
run.response["estimated_cost"]
.as_str()
.map(|cost| cost.starts_with('$'))
.unwrap_or(false),
"estimated_cost should be a dollar-prefixed string"
);
}
fn parse_json_output(stdout: &str) -> Value {
if let Some(index) = stdout.rfind("{\"auto_compaction\"") {
return serde_json::from_str(&stdout[index..]).unwrap_or_else(|error| {
panic!("failed to parse JSON response from stdout: {error}\n{stdout}")
});
}
stdout
.lines()
.rev()
.find_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
serde_json::from_str(trimmed).ok()
} else {
None
}
})
.unwrap_or_else(|| panic!("no JSON response line found in stdout:\n{stdout}"))
}
fn build_scenario_report(
name: &str,
manifest_entry: &ScenarioManifestEntry,
response: &Value,
) -> ScenarioReport {
ScenarioReport {
name: name.to_string(),
category: manifest_entry.category.clone(),
description: manifest_entry.description.clone(),
parity_refs: manifest_entry.parity_refs.clone(),
iterations: response["iterations"]
.as_u64()
.expect("iterations should exist"),
request_count: 0,
tool_uses: response["tool_uses"]
.as_array()
.expect("tool uses array")
.iter()
.filter_map(|value| value["name"].as_str().map(ToOwned::to_owned))
.collect(),
tool_error_count: response["tool_results"]
.as_array()
.expect("tool results array")
.iter()
.filter(|value| value["is_error"].as_bool().unwrap_or(false))
.count(),
final_message: response["message"]
.as_str()
.expect("message text")
.to_string(),
}
}
fn maybe_write_report(reports: &[ScenarioReport]) {
let Some(path) = std::env::var_os("MOCK_PARITY_REPORT_PATH") else {
return;
};
let payload = json!({
"scenario_count": reports.len(),
"request_count": reports.iter().map(|report| report.request_count).sum::<usize>(),
"scenarios": reports.iter().map(scenario_report_json).collect::<Vec<_>>(),
});
fs::write(
path,
serde_json::to_vec_pretty(&payload).expect("report json should serialize"),
)
.expect("report should write");
}
fn load_scenario_manifest() -> Vec<ScenarioManifestEntry> {
let manifest_path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mock_parity_scenarios.json");
let manifest = fs::read_to_string(&manifest_path).expect("scenario manifest should exist");
serde_json::from_str::<Vec<Value>>(&manifest)
.expect("scenario manifest should parse")
.into_iter()
.map(|entry| ScenarioManifestEntry {
name: entry["name"]
.as_str()
.expect("scenario name should be a string")
.to_string(),
category: entry["category"]
.as_str()
.expect("scenario category should be a string")
.to_string(),
description: entry["description"]
.as_str()
.expect("scenario description should be a string")
.to_string(),
parity_refs: entry["parity_refs"]
.as_array()
.expect("parity refs should be an array")
.iter()
.map(|value| {
value
.as_str()
.expect("parity ref should be a string")
.to_string()
})
.collect(),
})
.collect()
}
fn scenario_report_json(report: &ScenarioReport) -> Value {
json!({
"name": report.name,
"category": report.category,
"description": report.description,
"parity_refs": report.parity_refs,
"iterations": report.iterations,
"request_count": report.request_count,
"tool_uses": report.tool_uses,
"tool_error_count": report.tool_error_count,
"final_message": report.final_message,
})
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-mock-parity-{label}-{}-{millis}-{counter}",
std::process::id()
))
}

View File

@@ -1,247 +0,0 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::ContentBlock;
use runtime::Session;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn resumed_binary_accepts_slash_commands_with_arguments() {
// given
let temp_dir = unique_temp_dir("resume-slash-commands");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let export_path = temp_dir.join("notes.txt");
let mut session = Session::new();
session
.push_user_text("ship the slash command harness")
.expect("session write should succeed");
session
.save_to_path(&session_path)
.expect("session should persist");
// when
let output = run_claw(
&temp_dir,
&[
"--resume",
session_path.to_str().expect("utf8 path"),
"/export",
export_path.to_str().expect("utf8 path"),
"/clear",
"--confirm",
],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Export"));
assert!(stdout.contains("wrote transcript"));
assert!(stdout.contains(export_path.to_str().expect("utf8 path")));
assert!(stdout.contains("Session cleared"));
assert!(stdout.contains("Mode resumed session reset"));
assert!(stdout.contains("Previous session"));
assert!(stdout.contains("Resume previous claw --resume"));
assert!(stdout.contains("Backup "));
assert!(stdout.contains("Session file "));
let export = fs::read_to_string(&export_path).expect("export file should exist");
assert!(export.contains("# Conversation Export"));
assert!(export.contains("ship the slash command harness"));
let restored = Session::load_from_path(&session_path).expect("cleared session should load");
assert!(restored.messages.is_empty());
let backup_path = stdout
.lines()
.find_map(|line| line.strip_prefix(" Backup "))
.map(PathBuf::from)
.expect("clear output should include backup path");
let backup = Session::load_from_path(&backup_path).expect("backup session should load");
assert_eq!(backup.messages.len(), 1);
assert!(matches!(
backup.messages[0].blocks.first(),
Some(ContentBlock::Text { text }) if text == "ship the slash command harness"
));
}
#[test]
fn status_command_applies_cli_flags_end_to_end() {
// given
let temp_dir = unique_temp_dir("status-command-flags");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
// when
let output = run_claw(
&temp_dir,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
"status",
],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model claude-sonnet-4-6"));
assert!(stdout.contains("Permission mode read-only"));
}
#[test]
fn resumed_config_command_loads_settings_files_end_to_end() {
// given
let temp_dir = unique_temp_dir("resume-config");
let project_dir = temp_dir.join("project");
let config_home = temp_dir.join("home").join(".claw");
fs::create_dir_all(project_dir.join(".claw")).expect("project config dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
let session_path = project_dir.join("session.jsonl");
Session::new()
.with_persistence_path(&session_path)
.save_to_path(&session_path)
.expect("session should persist");
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
.expect("user config should write");
fs::write(
project_dir.join(".claw").join("settings.local.json"),
r#"{"model":"opus"}"#,
)
.expect("local config should write");
// when
let output = run_claw_with_env(
&project_dir,
&[
"--resume",
session_path.to_str().expect("utf8 path"),
"/config",
"model",
],
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Config"));
assert!(stdout.contains("Loaded files 2"));
assert!(stdout.contains(
config_home
.join("settings.json")
.to_str()
.expect("utf8 path")
));
assert!(stdout.contains(
project_dir
.join(".claw")
.join("settings.local.json")
.to_str()
.expect("utf8 path")
));
assert!(stdout.contains("Merged section: model"));
assert!(stdout.contains("opus"));
}
#[test]
fn resume_latest_restores_the_most_recent_managed_session() {
// given
let temp_dir = unique_temp_dir("resume-latest");
let project_dir = temp_dir.join("project");
let sessions_dir = project_dir.join(".claw").join("sessions");
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
let older_path = sessions_dir.join("session-older.jsonl");
let newer_path = sessions_dir.join("session-newer.jsonl");
let mut older = Session::new().with_persistence_path(&older_path);
older
.push_user_text("older session")
.expect("older session write should succeed");
older
.save_to_path(&older_path)
.expect("older session should persist");
let mut newer = Session::new().with_persistence_path(&newer_path);
newer
.push_user_text("newer session")
.expect("newer session write should succeed");
newer
.push_user_text("resume me")
.expect("newer session write should succeed");
newer
.save_to_path(&newer_path)
.expect("newer session should persist");
// when
let output = run_claw(&project_dir, &["--resume", "latest", "/status"]);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Messages 2"));
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
run_claw_with_env(current_dir, args, &[])
}
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args);
for (key, value) in envs {
command.env(key, value);
}
command.output().expect("claw should launch")
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-{label}-{}-{millis}-{counter}",
std::process::id()
))
}

View File

@@ -1,13 +0,0 @@
[package]
name = "telemetry"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints]
workspace = true

View File

@@ -1,526 +0,0 @@
use std::fmt::{Debug, Formatter};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_APP_NAME: &str = "claude-code";
pub const DEFAULT_RUNTIME: &str = "rust";
pub const DEFAULT_AGENTIC_BETA: &str = "claude-code-20250219";
pub const DEFAULT_PROMPT_CACHING_SCOPE_BETA: &str = "prompt-caching-scope-2026-01-05";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientIdentity {
pub app_name: String,
pub app_version: String,
pub runtime: String,
}
impl ClientIdentity {
#[must_use]
pub fn new(app_name: impl Into<String>, app_version: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
app_version: app_version.into(),
runtime: DEFAULT_RUNTIME.to_string(),
}
}
#[must_use]
pub fn with_runtime(mut self, runtime: impl Into<String>) -> Self {
self.runtime = runtime.into();
self
}
#[must_use]
pub fn user_agent(&self) -> String {
format!("{}/{}", self.app_name, self.app_version)
}
}
impl Default for ClientIdentity {
fn default() -> Self {
Self::new(DEFAULT_APP_NAME, env!("CARGO_PKG_VERSION"))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnthropicRequestProfile {
pub anthropic_version: String,
pub client_identity: ClientIdentity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub betas: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub extra_body: Map<String, Value>,
}
impl AnthropicRequestProfile {
#[must_use]
pub fn new(client_identity: ClientIdentity) -> Self {
Self {
anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
client_identity,
betas: vec![
DEFAULT_AGENTIC_BETA.to_string(),
DEFAULT_PROMPT_CACHING_SCOPE_BETA.to_string(),
],
extra_body: Map::new(),
}
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
let beta = beta.into();
if !self.betas.contains(&beta) {
self.betas.push(beta);
}
self
}
#[must_use]
pub fn with_extra_body(mut self, key: impl Into<String>, value: Value) -> Self {
self.extra_body.insert(key.into(), value);
self
}
#[must_use]
pub fn header_pairs(&self) -> Vec<(String, String)> {
let mut headers = vec![
(
"anthropic-version".to_string(),
self.anthropic_version.clone(),
),
("user-agent".to_string(), self.client_identity.user_agent()),
];
if !self.betas.is_empty() {
headers.push(("anthropic-beta".to_string(), self.betas.join(",")));
}
headers
}
pub fn render_json_body<T: Serialize>(&self, request: &T) -> Result<Value, serde_json::Error> {
let mut body = serde_json::to_value(request)?;
let object = body.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request body must serialize to a JSON object",
))
})?;
for (key, value) in &self.extra_body {
object.insert(key.clone(), value.clone());
}
if !self.betas.is_empty() {
object.insert(
"betas".to_string(),
Value::Array(self.betas.iter().cloned().map(Value::String).collect()),
);
}
Ok(body)
}
}
impl Default for AnthropicRequestProfile {
fn default() -> Self {
Self::new(ClientIdentity::default())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalyticsEvent {
pub namespace: String,
pub action: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub properties: Map<String, Value>,
}
impl AnalyticsEvent {
#[must_use]
pub fn new(namespace: impl Into<String>, action: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
action: action.into(),
properties: Map::new(),
}
}
#[must_use]
pub fn with_property(mut self, key: impl Into<String>, value: Value) -> Self {
self.properties.insert(key.into(), value);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionTraceRecord {
pub session_id: String,
pub sequence: u64,
pub name: String,
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub attributes: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TelemetryEvent {
HttpRequestStarted {
session_id: String,
attempt: u32,
method: String,
path: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestSucceeded {
session_id: String,
attempt: u32,
method: String,
path: String,
status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestFailed {
session_id: String,
attempt: u32,
method: String,
path: String,
error: String,
retryable: bool,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
Analytics(AnalyticsEvent),
SessionTrace(SessionTraceRecord),
}
pub trait TelemetrySink: Send + Sync {
fn record(&self, event: TelemetryEvent);
}
#[derive(Default)]
pub struct MemoryTelemetrySink {
events: Mutex<Vec<TelemetryEvent>>,
}
impl MemoryTelemetrySink {
#[must_use]
pub fn events(&self) -> Vec<TelemetryEvent> {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl TelemetrySink for MemoryTelemetrySink {
fn record(&self, event: TelemetryEvent) {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
}
pub struct JsonlTelemetrySink {
path: PathBuf,
file: Mutex<File>,
}
impl Debug for JsonlTelemetrySink {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonlTelemetrySink")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl JsonlTelemetrySink {
pub fn new(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
file: Mutex::new(file),
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl TelemetrySink for JsonlTelemetrySink {
fn record(&self, event: TelemetryEvent) {
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let mut file = self
.file
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, "{line}");
let _ = file.flush();
}
}
#[derive(Clone)]
pub struct SessionTracer {
session_id: String,
sequence: Arc<AtomicU64>,
sink: Arc<dyn TelemetrySink>,
}
impl Debug for SessionTracer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionTracer")
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl SessionTracer {
#[must_use]
pub fn new(session_id: impl Into<String>, sink: Arc<dyn TelemetrySink>) -> Self {
Self {
session_id: session_id.into(),
sequence: Arc::new(AtomicU64::new(0)),
sink,
}
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn record(&self, name: impl Into<String>, attributes: Map<String, Value>) {
let record = SessionTraceRecord {
session_id: self.session_id.clone(),
sequence: self.sequence.fetch_add(1, Ordering::Relaxed),
name: name.into(),
timestamp_ms: current_timestamp_ms(),
attributes,
};
self.sink.record(TelemetryEvent::SessionTrace(record));
}
pub fn record_http_request_started(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestStarted {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
attributes: attributes.clone(),
});
self.record(
"http_request_started",
merge_trace_fields(method, path, attempt, attributes),
);
}
pub fn record_http_request_succeeded(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
status: u16,
request_id: Option<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestSucceeded {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
status,
request_id: request_id.clone(),
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("status".to_string(), Value::from(status));
if let Some(request_id) = request_id {
trace_attributes.insert("request_id".to_string(), Value::String(request_id));
}
self.record("http_request_succeeded", trace_attributes);
}
pub fn record_http_request_failed(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
error: impl Into<String>,
retryable: bool,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
let error = error.into();
self.sink.record(TelemetryEvent::HttpRequestFailed {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
error: error.clone(),
retryable,
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("error".to_string(), Value::String(error));
trace_attributes.insert("retryable".to_string(), Value::Bool(retryable));
self.record("http_request_failed", trace_attributes);
}
pub fn record_analytics(&self, event: AnalyticsEvent) {
let mut attributes = event.properties.clone();
attributes.insert(
"namespace".to_string(),
Value::String(event.namespace.clone()),
);
attributes.insert("action".to_string(), Value::String(event.action.clone()));
self.sink.record(TelemetryEvent::Analytics(event));
self.record("analytics", attributes);
}
}
fn merge_trace_fields(
method: String,
path: String,
attempt: u32,
mut attributes: Map<String, Value>,
) -> Map<String, Value> {
attributes.insert("method".to_string(), Value::String(method));
attributes.insert("path".to_string(), Value::String(path));
attributes.insert("attempt".to_string(), Value::from(attempt));
attributes
}
fn current_timestamp_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_profile_emits_headers_and_merges_body() {
let profile = AnthropicRequestProfile::new(
ClientIdentity::new("claude-code", "1.2.3").with_runtime("rust-cli"),
)
.with_beta("tools-2026-04-01")
.with_extra_body("metadata", serde_json::json!({"source": "test"}));
assert_eq!(
profile.header_pairs(),
vec![
(
"anthropic-version".to_string(),
DEFAULT_ANTHROPIC_VERSION.to_string()
),
("user-agent".to_string(), "claude-code/1.2.3".to_string()),
(
"anthropic-beta".to_string(),
"claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01"
.to_string(),
),
]
);
let body = profile
.render_json_body(&serde_json::json!({"model": "claude-sonnet"}))
.expect("body should serialize");
assert_eq!(
body["metadata"]["source"],
Value::String("test".to_string())
);
assert_eq!(
body["betas"],
serde_json::json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
}
#[test]
fn session_tracer_records_structured_events_and_trace_sequence() {
let sink = Arc::new(MemoryTelemetrySink::default());
let tracer = SessionTracer::new("session-123", sink.clone());
tracer.record_http_request_started(1, "POST", "/v1/messages", Map::new());
tracer.record_analytics(
AnalyticsEvent::new("cli", "prompt_sent")
.with_property("model", Value::String("claude-opus".to_string())),
);
let events = sink.events();
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-123" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 0, name, .. })
if name == "http_request_started"
));
assert!(matches!(&events[2], TelemetryEvent::Analytics(_)));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 1, name, .. })
if name == "analytics"
));
}
#[test]
fn jsonl_sink_persists_events() {
let path =
std::env::temp_dir().join(format!("telemetry-jsonl-{}.log", current_timestamp_ms()));
let sink = JsonlTelemetrySink::new(&path).expect("sink should create file");
sink.record(TelemetryEvent::Analytics(
AnalyticsEvent::new("cli", "turn_completed").with_property("ok", Value::Bool(true)),
));
let contents = std::fs::read_to_string(&path).expect("telemetry log should be readable");
assert!(contents.contains("\"type\":\"analytics\""));
assert!(contents.contains("\"action\":\"turn_completed\""));
let _ = std::fs::remove_file(path);
}
}

View File

@@ -1,182 +0,0 @@
//! Lane completion detector — automatically marks lanes as completed when
//! session finishes successfully with green tests and pushed code.
//!
//! This bridges the gap where `LaneContext::completed` was a passive bool
//! that nothing automatically set. Now completion is detected from:
//! - Agent output shows Finished status
//! - No errors/blockers present
//! - Tests passed (green status)
//! - Code pushed (has output file)
use runtime::{
evaluate, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine, PolicyRule,
ReviewStatus,
};
use crate::AgentOutput;
/// Detects if a lane should be automatically marked as completed.
///
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
/// `None` if lane should remain active.
#[allow(dead_code)]
pub(crate) fn detect_lane_completion(
output: &AgentOutput,
test_green: bool,
has_pushed: bool,
) -> Option<LaneContext> {
// Must be finished without errors
if output.error.is_some() {
return None;
}
// Must have finished status
if !output.status.eq_ignore_ascii_case("completed")
&& !output.status.eq_ignore_ascii_case("finished")
{
return None;
}
// Must have no current blocker
if output.current_blocker.is_some() {
return None;
}
// Must have green tests
if !test_green {
return None;
}
// Must have pushed code
if !has_pushed {
return None;
}
// All conditions met — create completed context
Some(LaneContext {
lane_id: output.agent_id.clone(),
green_level: 3, // Workspace green
branch_freshness: std::time::Duration::from_secs(0),
blocker: LaneBlocker::None,
review_status: ReviewStatus::Approved,
diff_scope: runtime::DiffScope::Scoped,
completed: true,
reconciled: false,
})
}
/// Evaluates policy actions for a completed lane.
#[allow(dead_code)]
pub(crate) fn evaluate_completed_lane(
context: &LaneContext,
) -> Vec<PolicyAction> {
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"closeout-completed-lane",
PolicyCondition::And(vec![
PolicyCondition::LaneCompleted,
PolicyCondition::GreenAt { level: 3 },
]),
PolicyAction::CloseoutLane,
10,
),
PolicyRule::new(
"cleanup-completed-session",
PolicyCondition::LaneCompleted,
PolicyAction::CleanupSession,
5,
),
]);
evaluate(&engine, context)
}
#[cfg(test)]
mod tests {
use super::*;
use runtime::{DiffScope, LaneBlocker};
fn test_output() -> AgentOutput {
AgentOutput {
agent_id: "test-lane-1".to_string(),
name: "Test Agent".to_string(),
description: "Test".to_string(),
subagent_type: None,
model: None,
status: "Finished".to_string(),
output_file: "/tmp/test.output".to_string(),
manifest_file: "/tmp/test.manifest".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:00:00Z".to_string()),
completed_at: Some("2024-01-01T00:00:00Z".to_string()),
lane_events: vec![],
current_blocker: None,
error: None,
}
}
#[test]
fn detects_completion_when_all_conditions_met() {
let output = test_output();
let result = detect_lane_completion(&output, true, true);
assert!(result.is_some());
let context = result.unwrap();
assert!(context.completed);
assert_eq!(context.green_level, 3);
assert_eq!(context.blocker, LaneBlocker::None);
}
#[test]
fn no_completion_when_error_present() {
let mut output = test_output();
output.error = Some("Build failed".to_string());
let result = detect_lane_completion(&output, true, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_not_finished() {
let mut output = test_output();
output.status = "Running".to_string();
let result = detect_lane_completion(&output, true, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_tests_not_green() {
let output = test_output();
let result = detect_lane_completion(&output, false, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_not_pushed() {
let output = test_output();
let result = detect_lane_completion(&output, true, false);
assert!(result.is_none());
}
#[test]
fn evaluate_triggers_closeout_for_completed_lane() {
let context = LaneContext {
lane_id: "completed-lane".to_string(),
green_level: 3,
branch_freshness: std::time::Duration::from_secs(0),
blocker: LaneBlocker::None,
review_status: ReviewStatus::Approved,
diff_scope: DiffScope::Scoped,
completed: true,
reconciled: false,
};
let actions = evaluate_completed_lane(&context);
assert!(actions.contains(&PolicyAction::CloseoutLane));
assert!(actions.contains(&PolicyAction::CleanupSession));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +0,0 @@
[
{
"name": "streaming_text",
"category": "baseline",
"description": "Validates streamed assistant text with no tool calls.",
"parity_refs": [
"Mock parity harness \u2014 milestone 1",
"Streaming response support validated by the mock parity harness"
]
},
{
"name": "read_file_roundtrip",
"category": "file-tools",
"description": "Exercises read_file tool execution and final assistant synthesis.",
"parity_refs": [
"Mock parity harness \u2014 milestone 1",
"File tools \u2014 harness-validated flows"
]
},
{
"name": "grep_chunk_assembly",
"category": "file-tools",
"description": "Validates grep_search partial JSON chunk assembly and follow-up synthesis.",
"parity_refs": [
"Mock parity harness \u2014 milestone 1",
"File tools \u2014 harness-validated flows"
]
},
{
"name": "write_file_allowed",
"category": "file-tools",
"description": "Confirms workspace-write write_file success and filesystem side effects.",
"parity_refs": [
"Mock parity harness \u2014 milestone 1",
"File tools \u2014 harness-validated flows"
]
},
{
"name": "write_file_denied",
"category": "permissions",
"description": "Confirms read-only mode blocks write_file with an error result.",
"parity_refs": [
"Mock parity harness \u2014 milestone 1",
"Permission enforcement across tool paths"
]
},
{
"name": "multi_tool_turn_roundtrip",
"category": "multi-tool-turns",
"description": "Executes read_file and grep_search in the same assistant turn before the final reply.",
"parity_refs": [
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
"Multi-tool assistant turns"
]
},
{
"name": "bash_stdout_roundtrip",
"category": "bash",
"description": "Validates bash execution and stdout roundtrip in danger-full-access mode.",
"parity_refs": [
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
"Bash tool \u2014 upstream has 18 submodules, Rust has 1:"
]
},
{
"name": "bash_permission_prompt_approved",
"category": "permissions",
"description": "Exercises workspace-write to bash escalation with a positive approval response.",
"parity_refs": [
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
"Permission enforcement across tool paths"
]
},
{
"name": "bash_permission_prompt_denied",
"category": "permissions",
"description": "Exercises workspace-write to bash escalation with a denied approval response.",
"parity_refs": [
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
"Permission enforcement across tool paths"
]
},
{
"name": "plugin_tool_roundtrip",
"category": "plugin-paths",
"description": "Loads an external plugin tool and executes it through the runtime tool registry.",
"parity_refs": [
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
"Plugin tool execution path"
]
},
{
"name": "auto_compact_triggered",
"category": "session-compaction",
"description": "Verifies auto-compact fires when cumulative input tokens exceed the configured threshold.",
"parity_refs": [
"Session compaction behavior matching",
"auto_compaction threshold from env"
]
},
{
"name": "token_cost_reporting",
"category": "token-usage",
"description": "Confirms usage token counts and estimated_cost appear in JSON output.",
"parity_refs": [
"Token counting / cost tracking accuracy"
]
}
]

View File

@@ -1,130 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
def load_manifest(path: Path) -> list[dict]:
return json.loads(path.read_text())
def load_parity_text(path: Path) -> str:
return path.read_text()
def ensure_refs_exist(manifest: list[dict], parity_text: str) -> list[tuple[str, str]]:
missing: list[tuple[str, str]] = []
for entry in manifest:
for ref in entry.get("parity_refs", []):
if ref not in parity_text:
missing.append((entry["name"], ref))
return missing
def run_harness(rust_root: Path) -> dict:
with tempfile.TemporaryDirectory(prefix="mock-parity-report-") as temp_dir:
report_path = Path(temp_dir) / "report.json"
env = os.environ.copy()
env["MOCK_PARITY_REPORT_PATH"] = str(report_path)
subprocess.run(
[
"cargo",
"test",
"-p",
"rusty-claude-cli",
"--test",
"mock_parity_harness",
"--",
"--nocapture",
],
cwd=rust_root,
check=True,
env=env,
)
return json.loads(report_path.read_text())
def main() -> int:
script_path = Path(__file__).resolve()
rust_root = script_path.parent.parent
repo_root = rust_root.parent
manifest = load_manifest(rust_root / "mock_parity_scenarios.json")
parity_text = load_parity_text(repo_root / "PARITY.md")
missing_refs = ensure_refs_exist(manifest, parity_text)
if missing_refs:
print("Missing PARITY.md references:", file=sys.stderr)
for scenario_name, ref in missing_refs:
print(f" - {scenario_name}: {ref}", file=sys.stderr)
return 1
should_run = "--no-run" not in sys.argv[1:]
report = run_harness(rust_root) if should_run else None
report_by_name = {
entry["name"]: entry for entry in report.get("scenarios", [])
} if report else {}
print("Mock parity diff checklist")
print(f"Repo root: {repo_root}")
print(f"Scenario manifest: {rust_root / 'mock_parity_scenarios.json'}")
print(f"PARITY source: {repo_root / 'PARITY.md'}")
print()
for entry in manifest:
scenario_name = entry["name"]
scenario_report = report_by_name.get(scenario_name)
status = "PASS" if scenario_report else ("MAPPED" if not should_run else "MISSING")
print(f"[{status}] {scenario_name} ({entry['category']})")
print(f" description: {entry['description']}")
print(f" parity refs: {' | '.join(entry['parity_refs'])}")
if scenario_report:
print(
" result: iterations={iterations} requests={requests} tool_uses={tool_uses} tool_errors={tool_errors}".format(
iterations=scenario_report["iterations"],
requests=scenario_report["request_count"],
tool_uses=", ".join(scenario_report["tool_uses"]) or "none",
tool_errors=scenario_report["tool_error_count"],
)
)
print(f" final: {scenario_report['final_message']}")
print()
coverage = defaultdict(list)
for entry in manifest:
for ref in entry["parity_refs"]:
coverage[ref].append(entry["name"])
print("PARITY coverage map")
for ref, scenarios in coverage.items():
print(f"- {ref}")
print(f" scenarios: {', '.join(scenarios)}")
if report and report.get("scenarios"):
first = report["scenarios"][0]
print()
print("First scenario result")
print(f"- name: {first['name']}")
print(f"- iterations: {first['iterations']}")
print(f"- requests: {first['request_count']}")
print(f"- tool_uses: {', '.join(first['tool_uses']) or 'none'}")
print(f"- tool_errors: {first['tool_error_count']}")
print(f"- final_message: {first['final_message']}")
print()
print(
"Harness summary: {scenario_count} scenarios, {request_count} requests".format(
scenario_count=report["scenario_count"],
request_count=report["request_count"],
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
cargo test -p rusty-claude-cli --test mock_parity_harness -- --nocapture

View File

@@ -1,17 +0,0 @@
"""Shared helper for archive placeholder packages."""
from __future__ import annotations
import json
from pathlib import Path
def load_archive_metadata(package_name: str) -> dict:
"""Load archive metadata from reference_data/subsystems/{package_name}.json."""
snapshot_path = (
Path(__file__).resolve().parent
/ "reference_data"
/ "subsystems"
/ f"{package_name}.json"
)
return json.loads(snapshot_path.read_text())

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("assistant")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'assistant.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("bootstrap")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'bootstrap.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("bridge")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'bridge.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("buddy")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'buddy.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("cli")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'cli.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("components")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'components.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("constants")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'constants.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("coordinator")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'coordinator.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("entrypoints")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'entrypoints.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("hooks")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'hooks.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("keybindings")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'keybindings.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("memdir")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'memdir.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("migrations")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'migrations.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("moreright")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'moreright.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -1,14 +1,16 @@
"""Python package placeholder for the archived `native_ts` subsystem."""
"""Python package placeholder for the archived `native-ts` subsystem."""
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("native_ts")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'native_ts.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("outputStyles")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'outputStyles.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from src._archive_helper import load_archive_metadata
import json
from pathlib import Path
_SNAPSHOT = load_archive_metadata("plugins")
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'plugins.json'
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
MODULE_COUNT = _SNAPSHOT["module_count"]
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
ARCHIVE_NAME = _SNAPSHOT['archive_name']
MODULE_COUNT = _SNAPSHOT['module_count']
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']

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