mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-07 10:22:45 -04:00
Compare commits
98 Commits
claw-code-
...
fix/batch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c08bc982 | ||
|
|
553d25ee50 | ||
|
|
5be173edf6 | ||
|
|
28998422e2 | ||
|
|
b4733b67a6 | ||
|
|
ab44985916 | ||
|
|
d074d1c046 | ||
|
|
caeac828b5 | ||
|
|
85435ad4b5 | ||
|
|
5eb4b8a944 | ||
|
|
65aa559733 | ||
|
|
ac8a24b30b | ||
|
|
94b80a05d3 | ||
|
|
9b97c4d832 | ||
|
|
1206f4131d | ||
|
|
c99330372c | ||
|
|
9a512633a5 | ||
|
|
6ac13ffdad | ||
|
|
482681cdfe | ||
|
|
8e45f1850c | ||
|
|
57096b0a1a | ||
|
|
51b9e6b37f | ||
|
|
e939777f92 | ||
|
|
1093e26792 | ||
|
|
44cca2054d | ||
|
|
6dc7b26d82 | ||
|
|
a0bd406c8f | ||
|
|
b62646edfe | ||
|
|
d95b230cae | ||
|
|
f48f156754 | ||
|
|
52a909cebe | ||
|
|
c4c618e476 | ||
|
|
74338dc635 | ||
|
|
c092cf7fef | ||
|
|
8e24f3049e | ||
|
|
71d8e7b925 | ||
|
|
19947545e2 | ||
|
|
f7b2d8d6fe | ||
|
|
6f92e54dc0 | ||
|
|
31d9198a02 | ||
|
|
5eb1d7d824 | ||
|
|
3b03375e69 | ||
|
|
0f9e8915be | ||
|
|
ab95b75fcd | ||
|
|
ee44ff984d | ||
|
|
2ab26df4bd | ||
|
|
a2a38df9b8 | ||
|
|
fd90c9fe67 | ||
|
|
cca6f6829c | ||
|
|
c77d1a87e1 | ||
|
|
ee41b266d3 | ||
|
|
ca92c695f4 | ||
|
|
c6c01beaca | ||
|
|
970cdc925e | ||
|
|
b2f7a3354f | ||
|
|
2a08b7a35c | ||
|
|
a510f73422 | ||
|
|
1283c6d532 | ||
|
|
a1bfcd4110 | ||
|
|
c49839bb1f | ||
|
|
f65b2b4f0e | ||
|
|
f4b74e89dd | ||
|
|
5856913104 | ||
|
|
d45a0d2f5b | ||
|
|
dc47482e40 | ||
|
|
9537c97231 | ||
|
|
f56a5afcf7 | ||
|
|
3efaf551ed | ||
|
|
30c9b438ef | ||
|
|
587bb18572 | ||
|
|
24ccb59bd2 | ||
|
|
0e8e75ef75 | ||
|
|
0f7578c064 | ||
|
|
213d406cbf | ||
|
|
ee85fed6ca | ||
|
|
3a34d83749 | ||
|
|
981aff7c8b | ||
|
|
c94940effa | ||
|
|
b90875fa8e | ||
|
|
2567cbcc78 | ||
|
|
d607ff3674 | ||
|
|
cdf6282965 | ||
|
|
e7074f47ee | ||
|
|
9468383b67 | ||
|
|
1da2781816 | ||
|
|
9037430d52 | ||
|
|
8e22f757d8 | ||
|
|
7676b376ae | ||
|
|
1011a83823 | ||
|
|
1376d92064 | ||
|
|
be53e04671 | ||
|
|
cb56dc12ab | ||
|
|
71686a20fc | ||
|
|
07992b8a1b | ||
|
|
74ea754d29 | ||
|
|
77afde768c | ||
|
|
6db68a2baa | ||
|
|
5b910356a2 |
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- Frameworks: none detected from the supported starter markers.
|
- Frameworks: none detected from the supported starter markers.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
- Run Rust verification from repo root: `scripts/fmt.sh --check`; for formatting use `scripts/fmt.sh`. Run Rust clippy/tests from `rust/`: `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||||
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
|
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
|
||||||
|
|
||||||
## Repository shape
|
## Repository shape
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -1,13 +1,5 @@
|
|||||||
# Claw Code
|
# Claw Code
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong>188K GitHub stars and climbing.</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong>Rust-native agent execution for people who want speed, control, and a real terminal.</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
|
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
|
||||||
·
|
·
|
||||||
@@ -36,21 +28,8 @@
|
|||||||
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
|
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
Claw Code is the public Rust implementation of the `claw` CLI agent harness.
|
||||||
Claw Code just crossed <strong>188,000 GitHub stars</strong>. This repo is the public Rust implementation of the <code>claw</code> CLI agent harness, built in the open with the UltraWorkers community.
|
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**.
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
The canonical implementation lives in <a href="./rust/">rust/</a>, and the current source of truth for this repository is <strong>ultraworkers/claw-code</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## 188K and climbing
|
|
||||||
|
|
||||||
Thanks to everyone who starred, tested, reviewed, and pushed the project forward. Claw Code is focused on a straightforward promise: a fast local-first CLI agent runtime with native tools, inspectable behavior, and a Rust workspace that stays close to the metal.
|
|
||||||
|
|
||||||
- Native Rust workspace and CLI binary under [`rust/`](./rust)
|
|
||||||
- Local-first workflows for prompts, sessions, tooling, and parity validation
|
|
||||||
- Open development across the broader UltraWorkers ecosystem
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
|
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
|
||||||
|
|||||||
197
ROADMAP.md
197
ROADMAP.md
File diff suppressed because one or more lines are too long
245
progress.txt
245
progress.txt
@@ -74,6 +74,18 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
|
|||||||
- DegradedMode behavior
|
- DegradedMode behavior
|
||||||
- Tests: 11 unit tests passing
|
- Tests: 11 unit tests passing
|
||||||
|
|
||||||
|
|
||||||
|
Iteration 2026-04-27 - ROADMAP #200 COMPLETED
|
||||||
|
------------------------------------------------
|
||||||
|
- Selected next actionable backlog item because no active task was in progress.
|
||||||
|
- ROADMAP #200: Interactive MCP/tool permission prompts are invisible blockers.
|
||||||
|
- Files: rust/crates/runtime/src/worker_boot.rs, rust/crates/runtime/src/recovery_recipes.rs, ROADMAP.md, progress.txt.
|
||||||
|
- Added tool_permission_required worker status and event classification for interactive MCP/tool permission gates.
|
||||||
|
- Added structured ToolPermissionPrompt payload with server/tool identity and prompt preview.
|
||||||
|
- Startup evidence now records tool_permission_prompt_detected and classifies timeout evidence as tool_permission_required.
|
||||||
|
- Readiness snapshots now mark tool-permission-gated workers as blocked, not ready/idle.
|
||||||
|
- Tests: targeted tool_permission regressions, full runtime test/clippy/fmt pending in Ralph verification loop.
|
||||||
|
|
||||||
VERIFICATION STATUS:
|
VERIFICATION STATUS:
|
||||||
------------------
|
------------------
|
||||||
- cargo build --workspace: PASSED
|
- cargo build --workspace: PASSED
|
||||||
@@ -108,6 +120,29 @@ US-010 COMPLETED (Add model compatibility documentation)
|
|||||||
- Cross-referenced with existing code comments in openai_compat.rs
|
- Cross-referenced with existing code comments in openai_compat.rs
|
||||||
- cargo clippy passes
|
- cargo clippy passes
|
||||||
|
|
||||||
|
Iteration 3: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-012 COMPLETED (Trust prompt resolver with allowlist auto-trust)
|
||||||
|
- Files: rust/crates/runtime/src/trust_resolver.rs
|
||||||
|
- Enhanced TrustConfig with pattern matching and serde support:
|
||||||
|
- TrustAllowlistEntry struct with pattern, worktree_pattern, description
|
||||||
|
- TrustResolution enum (AutoAllowlisted, ManualApproval)
|
||||||
|
- Enhanced TrustEvent variants with serde tags and metadata
|
||||||
|
- Glob pattern matching with * and ? wildcards
|
||||||
|
- Support for path prefix matching and worktree patterns
|
||||||
|
- Updated TrustResolver with new resolve() signature:
|
||||||
|
- Added worktree parameter for worktree pattern matching
|
||||||
|
- Proper event emission with TrustResolution
|
||||||
|
- Manual approval detection from screen text
|
||||||
|
- Added helper functions:
|
||||||
|
- extract_repo_name() - extracts repo name from path
|
||||||
|
- detect_manual_approval() - detects manual trust from screen text
|
||||||
|
- glob_matches() - recursive backtracking glob matcher
|
||||||
|
- Tests: 25 new tests for pattern matching, serialization, and resolver behavior
|
||||||
|
- All 483 runtime tests pass
|
||||||
|
- cargo clippy passes with no warnings
|
||||||
|
|
||||||
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
|
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
|
||||||
- Files:
|
- Files:
|
||||||
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
|
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
|
||||||
@@ -131,3 +166,213 @@ US-011 COMPLETED (Performance optimization: reduce API request serialization ove
|
|||||||
- is_reasoning_model detection: ~26-42ns depending on model
|
- is_reasoning_model detection: ~26-42ns depending on model
|
||||||
- All tests pass (119 unit tests + 29 integration tests)
|
- All tests pass (119 unit tests + 29 integration tests)
|
||||||
- cargo clippy passes
|
- cargo clippy passes
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 3):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
All 12 stories from prd.json now have passes: true
|
||||||
|
- US-001 through US-007: Pre-existing implementations
|
||||||
|
- US-008: kimi-k2.5 model API compatibility fix
|
||||||
|
- US-009: Unit tests for kimi model compatibility
|
||||||
|
- US-010: Model compatibility documentation
|
||||||
|
- US-011: Performance optimization with criterion benchmarks
|
||||||
|
- US-012: Trust prompt resolver with allowlist auto-trust
|
||||||
|
|
||||||
|
Iteration 4: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-013 COMPLETED (Phase 2 - Session event ordering + terminal-state reconciliation)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added EventTerminality enum (Terminal, Advisory, Uncertainty)
|
||||||
|
- Added classify_event_terminality() function for event classification
|
||||||
|
- Added reconcile_terminal_events() function for deterministic event ordering:
|
||||||
|
- Sorts events by monotonic sequence number
|
||||||
|
- Deduplicates terminal events by fingerprint
|
||||||
|
- Detects transport death uncertainty (terminal + transport death)
|
||||||
|
- Handles out-of-order event bursts
|
||||||
|
- Added events_materially_differ() for detecting meaningful differences
|
||||||
|
- Added 8 comprehensive tests for reconciliation logic:
|
||||||
|
- reconcile_terminal_events_sorts_by_monotonic_sequence
|
||||||
|
- reconcile_terminal_events_deduplicates_same_fingerprint
|
||||||
|
- reconcile_terminal_events_detects_transport_death_uncertainty
|
||||||
|
- reconcile_terminal_events_handles_completed_idle_error_completed_noise
|
||||||
|
- reconcile_terminal_events_returns_none_for_empty_input
|
||||||
|
- reconcile_terminal_events_preserves_advisory_events
|
||||||
|
- events_materially_differ_detects_real_differences
|
||||||
|
- classify_event_terminality_correctly_classifies
|
||||||
|
- Fixed test compilation issues with LaneEventBuilder API
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 4):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
US-013 marked passes: true in prd.json
|
||||||
|
|
||||||
|
US-014 COMPLETED (Phase 2 - Event provenance / environment labeling)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added ConfidenceLevel enum (High, Medium, Low, Unknown)
|
||||||
|
- Added fields to LaneEventMetadata:
|
||||||
|
- environment_label: Option<String> - environment/channel (production, staging, dev)
|
||||||
|
- emitter_identity: Option<String> - emitter (clawd, plugin-name, operator-id)
|
||||||
|
- confidence_level: Option<ConfidenceLevel> - trust level for automation
|
||||||
|
- Added builder methods: with_environment(), with_emitter(), with_confidence()
|
||||||
|
- Added filtering functions:
|
||||||
|
- filter_by_provenance() - select events by source
|
||||||
|
- filter_by_environment() - select events by environment label
|
||||||
|
- filter_by_confidence() - select events above confidence threshold
|
||||||
|
- is_test_event() - check if synthetic source (test, healthcheck, replay)
|
||||||
|
- is_live_lane_event() - check if production event
|
||||||
|
- Added 7 comprehensive tests for US-014:
|
||||||
|
- confidence_level_round_trips_through_serialization
|
||||||
|
- filter_by_provenance_selects_only_matching_events
|
||||||
|
- filter_by_environment_selects_only_matching_environment
|
||||||
|
- filter_by_confidence_selects_events_above_threshold
|
||||||
|
- is_test_event_detects_synthetic_sources
|
||||||
|
- is_live_lane_event_detects_production_events
|
||||||
|
- lane_event_metadata_includes_us014_fields
|
||||||
|
|
||||||
|
US-016 COMPLETED (Phase 2 - Duplicate terminal-event suppression)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Event fingerprinting already implemented via compute_event_fingerprint()
|
||||||
|
- Fingerprint attached via LaneEventMetadata.event_fingerprint
|
||||||
|
- Deduplication via dedupe_terminal_events() - returns first occurrence of each fingerprint
|
||||||
|
- Raw event history preserved separately from deduplicated actionable events
|
||||||
|
- Material difference detection via events_materially_differ():
|
||||||
|
- Different event type (Finished vs Failed) is material
|
||||||
|
- Different status is material
|
||||||
|
- Different failure class is material
|
||||||
|
- Different data payload is material
|
||||||
|
- Reconcile function surfaces latest terminal event when materially different
|
||||||
|
- Added 5 comprehensive tests for US-016:
|
||||||
|
- canonical_terminal_event_fingerprint_attached_to_metadata
|
||||||
|
- dedupe_terminal_events_suppresses_repeated_fingerprints
|
||||||
|
- dedupe_preserves_raw_event_history_separately
|
||||||
|
- events_materially_differ_detects_payload_differences
|
||||||
|
- reconcile_terminal_events_surfaces_latest_when_different
|
||||||
|
|
||||||
|
US-017 COMPLETED (Phase 2 - Lane ownership / scope binding)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- LaneOwnership struct already existed with:
|
||||||
|
- owner: String - owner/assignee identity
|
||||||
|
- workflow_scope: String - workflow scope (claw-code-dogfood, etc.)
|
||||||
|
- watcher_action: WatcherAction - Act, Observe, Ignore
|
||||||
|
- Ownership preserved through lifecycle via with_ownership() builder method
|
||||||
|
- All lifecycle events (Started -> Ready -> Finished) preserve ownership
|
||||||
|
- Added 3 comprehensive tests for US-017:
|
||||||
|
- lane_ownership_attached_to_metadata
|
||||||
|
- lane_ownership_preserved_through_lifecycle_events
|
||||||
|
- lane_ownership_watcher_action_variants
|
||||||
|
|
||||||
|
US-015 COMPLETED (Phase 2 - Session identity completeness at creation time)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- SessionIdentity struct already existed with:
|
||||||
|
- title: String - stable title for the session
|
||||||
|
- workspace: String - workspace/worktree path
|
||||||
|
- purpose: String - lane/session purpose
|
||||||
|
- placeholder_reason: Option<String> - reason for placeholder values
|
||||||
|
- Added reconcile_enriched() method for updating session identity:
|
||||||
|
- Updates title/workspace/purpose with newly available data
|
||||||
|
- Clears placeholder_reason when real values are provided
|
||||||
|
- Preserves existing values for fields not being updated
|
||||||
|
- Allows incremental enrichment without ambiguity
|
||||||
|
- Added 2 comprehensive tests:
|
||||||
|
- session_identity_reconcile_enriched_updates_fields
|
||||||
|
- session_identity_reconcile_preserves_placeholder_if_no_new_data
|
||||||
|
|
||||||
|
US-018 COMPLETED (Phase 2 - Nudge acknowledgment / dedupe contract)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added NudgeTracking struct:
|
||||||
|
- nudge_id: String - unique nudge identifier
|
||||||
|
- delivered_at: String - timestamp of delivery
|
||||||
|
- acknowledged: bool - whether acknowledged
|
||||||
|
- acknowledged_at: Option<String> - when acknowledged
|
||||||
|
- is_retry: bool - whether this is a retry
|
||||||
|
- original_nudge_id: Option<String> - original ID if retry
|
||||||
|
- Added NudgeClassification enum (New, Retry, StaleDuplicate)
|
||||||
|
- Added classify_nudge() function for deduplication logic
|
||||||
|
- Added 6 comprehensive tests for US-018
|
||||||
|
|
||||||
|
US-019 COMPLETED (Phase 2 - Stable roadmap-id assignment)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added RoadmapId struct:
|
||||||
|
- id: String - canonical unique identifier
|
||||||
|
- filed_at: String - timestamp when filed
|
||||||
|
- is_new_filing: bool - new vs update
|
||||||
|
- supersedes: Option<String> - lineage for supersedes
|
||||||
|
- Added builder methods: new_filing(), update(), supersedes()
|
||||||
|
- Added 3 comprehensive tests for US-019
|
||||||
|
|
||||||
|
US-020 COMPLETED (Phase 2 - Roadmap item lifecycle state contract)
|
||||||
|
- Files: rust/crates/runtime/src/lane_events.rs
|
||||||
|
- Added RoadmapLifecycleState enum (Filed, Acknowledged, InProgress, Blocked, Done, Superseded)
|
||||||
|
- Added RoadmapLifecycle struct:
|
||||||
|
- state: RoadmapLifecycleState - current state
|
||||||
|
- state_changed_at: String - last transition timestamp
|
||||||
|
- filed_at: String - original filing timestamp
|
||||||
|
- lineage: Vec<String> - supersession chain
|
||||||
|
- Added methods: new_filed(), transition(), superseded_by(), is_terminal(), is_active()
|
||||||
|
- Added 5 comprehensive tests for US-020
|
||||||
|
|
||||||
|
VERIFICATION STATUS (Iteration 7):
|
||||||
|
----------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (891+ tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
US-013 through US-015 and US-018 through US-020 now marked passes: true
|
||||||
|
|
||||||
|
FINAL VERIFICATION (All 20 Stories Complete):
|
||||||
|
------------------------------------------------
|
||||||
|
- cargo build --workspace: PASSED
|
||||||
|
- cargo test --workspace: PASSED (119+ API tests, 39 runtime tests, 12 integration tests)
|
||||||
|
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||||
|
- cargo fmt -- --check: PASSED
|
||||||
|
|
||||||
|
ALL 20 STORIES FROM PRD COMPLETE:
|
||||||
|
- US-001 through US-012: Pre-existing implementations (verified working)
|
||||||
|
- US-013: Session event ordering + terminal-state reconciliation
|
||||||
|
- US-014: Event provenance / environment labeling
|
||||||
|
- US-015: Session identity completeness at creation time
|
||||||
|
- US-016: Duplicate terminal-event suppression
|
||||||
|
- US-017: Lane ownership / scope binding
|
||||||
|
- US-018: Nudge acknowledgment / dedupe contract
|
||||||
|
- US-019: Stable roadmap-id assignment
|
||||||
|
- US-020: Roadmap item lifecycle state contract
|
||||||
|
|
||||||
|
Iteration 8: 2026-04-16
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
|
||||||
|
- Files:
|
||||||
|
- rust/crates/api/src/error.rs (new error variant)
|
||||||
|
- rust/crates/api/src/providers/openai_compat.rs
|
||||||
|
- Added RequestBodySizeExceeded error variant with actionable message
|
||||||
|
- Added max_request_body_bytes to OpenAiCompatConfig:
|
||||||
|
- DashScope: 6MB (6_291_456 bytes) - from dogfood with kimi-k2.5
|
||||||
|
- OpenAI: 100MB (104_857_600 bytes)
|
||||||
|
- xAI: 50MB (52_428_800 bytes)
|
||||||
|
- Added estimate_request_body_size() for pre-flight checks
|
||||||
|
- Added check_request_body_size() for validation
|
||||||
|
- Pre-flight check integrated in send_raw_request()
|
||||||
|
- Tests: 5 new tests for size estimation and limit checking
|
||||||
|
|
||||||
|
PROJECT STATUS: COMPLETE (21/21 stories)
|
||||||
|
|
||||||
|
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
|
||||||
|
------------------------------------------------
|
||||||
|
- Pulled origin/main: already up to date.
|
||||||
|
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
|
||||||
|
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
|
||||||
|
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
|
||||||
|
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
|
||||||
|
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
|
||||||
|
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
|
||||||
|
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ This file provides guidance to Claw Code (clawcode.dev) when working with code i
|
|||||||
- Frameworks: none detected from the supported starter markers.
|
- Frameworks: none detected from the supported starter markers.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
|
||||||
|
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
|
||||||
|
|
||||||
## Working agreement
|
## Working agreement
|
||||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
|||||||
"too many tokens",
|
"too many tokens",
|
||||||
"prompt is too long",
|
"prompt is too long",
|
||||||
"input is too long",
|
"input is too long",
|
||||||
|
"input tokens exceed",
|
||||||
|
"configured limit",
|
||||||
|
"messages resulted in",
|
||||||
|
"completion tokens",
|
||||||
|
"prompt tokens",
|
||||||
"request is too large",
|
"request is too large",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -542,6 +547,26 @@ mod tests {
|
|||||||
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||||
|
let error = ApiError::Api {
|
||||||
|
status: reqwest::StatusCode::BAD_REQUEST,
|
||||||
|
error_type: Some("invalid_request_error".to_string()),
|
||||||
|
message: Some(
|
||||||
|
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
request_id: Some("req_ctx_openai_123".to_string()),
|
||||||
|
body: String::new(),
|
||||||
|
retryable: false,
|
||||||
|
suggested_action: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(error.is_context_window_failure());
|
||||||
|
assert_eq!(error.safe_failure_class(), "context_window");
|
||||||
|
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ pub use prompt_cache::{
|
|||||||
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
|
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
|
||||||
pub use providers::openai_compat::{
|
pub use providers::openai_compat::{
|
||||||
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
|
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
|
||||||
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
|
model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message,
|
||||||
|
OpenAiCompatClient, OpenAiCompatConfig,
|
||||||
};
|
};
|
||||||
pub use providers::{
|
pub use providers::{
|
||||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||||
resolve_model_alias, ProviderKind,
|
model_family_identity_for, model_family_identity_for_kind, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
|
|||||||
@@ -250,19 +250,31 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||||||
ProviderKind::Anthropic
|
ProviderKind::Anthropic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity {
|
||||||
|
match kind {
|
||||||
|
ProviderKind::Anthropic => runtime::ModelFamilyIdentity::Claude,
|
||||||
|
ProviderKind::Xai | ProviderKind::OpenAi => runtime::ModelFamilyIdentity::Generic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_family_identity_for(model: &str) -> runtime::ModelFamilyIdentity {
|
||||||
|
model_family_identity_for_kind(detect_provider_kind(model))
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||||
model_token_limit(model).map_or_else(
|
let canonical = resolve_model_alias(model);
|
||||||
|| {
|
let heuristic = if canonical.contains("opus") {
|
||||||
let canonical = resolve_model_alias(model);
|
32_000
|
||||||
if canonical.contains("opus") {
|
} else {
|
||||||
32_000
|
64_000
|
||||||
} else {
|
};
|
||||||
64_000
|
|
||||||
}
|
model_token_limit(model)
|
||||||
},
|
.map(|limit| heuristic.min(limit.max_output_tokens))
|
||||||
|limit| limit.max_output_tokens,
|
.unwrap_or(heuristic)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||||
@@ -276,7 +288,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||||
let canonical = resolve_model_alias(model);
|
let canonical = resolve_model_alias(model);
|
||||||
match canonical.as_str() {
|
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||||
|
match base_model {
|
||||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||||
max_output_tokens: 32_000,
|
max_output_tokens: 32_000,
|
||||||
context_window_tokens: 200_000,
|
context_window_tokens: 200_000,
|
||||||
@@ -289,6 +302,20 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||||||
max_output_tokens: 64_000,
|
max_output_tokens: 64_000,
|
||||||
context_window_tokens: 131_072,
|
context_window_tokens: 131_072,
|
||||||
}),
|
}),
|
||||||
|
// GPT-4.1 family via the OpenAI API.
|
||||||
|
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 32_768,
|
||||||
|
context_window_tokens: 1_047_576,
|
||||||
|
}),
|
||||||
|
// GPT-5.4 family via the OpenAI API.
|
||||||
|
"gpt-5.4" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 128_000,
|
||||||
|
context_window_tokens: 1_000_000,
|
||||||
|
}),
|
||||||
|
"gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 128_000,
|
||||||
|
context_window_tokens: 400_000,
|
||||||
|
}),
|
||||||
// Kimi models via DashScope (Moonshot AI)
|
// Kimi models via DashScope (Moonshot AI)
|
||||||
// Source: https://platform.moonshot.cn/docs/intro
|
// Source: https://platform.moonshot.cn/docs/intro
|
||||||
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
|
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
|
||||||
@@ -470,8 +497,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
||||||
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||||
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
|
model_family_identity_for, model_family_identity_for_kind, model_token_limit, parse_dotenv,
|
||||||
ProviderKind,
|
preflight_message_request, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Serializes every test in this module that mutates process-wide
|
/// Serializes every test in this module that mutates process-wide
|
||||||
@@ -530,6 +557,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_provider_kind_to_model_family_identity() {
|
||||||
|
// given: each supported provider kind
|
||||||
|
let anthropic = ProviderKind::Anthropic;
|
||||||
|
let openai = ProviderKind::OpenAi;
|
||||||
|
let xai = ProviderKind::Xai;
|
||||||
|
|
||||||
|
// when: converting provider kinds to prompt model family identities
|
||||||
|
let anthropic_identity = model_family_identity_for_kind(anthropic);
|
||||||
|
let openai_identity = model_family_identity_for_kind(openai);
|
||||||
|
let xai_identity = model_family_identity_for_kind(xai);
|
||||||
|
|
||||||
|
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||||
|
assert_eq!(anthropic_identity, runtime::ModelFamilyIdentity::Claude);
|
||||||
|
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_model_name_to_model_family_identity() {
|
||||||
|
// given: Anthropic, OpenAI-compatible, and xAI model names
|
||||||
|
let claude_model = "claude-opus-4-6";
|
||||||
|
let openai_model = "openai/gpt-4.1-mini";
|
||||||
|
let xai_model = "grok-3";
|
||||||
|
|
||||||
|
// when: detecting prompt model family identities from model names
|
||||||
|
let claude_identity = model_family_identity_for(claude_model);
|
||||||
|
let openai_identity = model_family_identity_for(openai_model);
|
||||||
|
let xai_identity = model_family_identity_for(xai_model);
|
||||||
|
|
||||||
|
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||||
|
assert_eq!(claude_identity, runtime::ModelFamilyIdentity::Claude);
|
||||||
|
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
||||||
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
||||||
@@ -614,6 +677,15 @@ mod tests {
|
|||||||
fn keeps_existing_max_token_heuristic() {
|
fn keeps_existing_max_token_heuristic() {
|
||||||
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
||||||
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caps_default_max_tokens_to_openai_model_limits() {
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768);
|
||||||
|
assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768);
|
||||||
|
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||||
|
assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -680,6 +752,18 @@ mod tests {
|
|||||||
.context_window_tokens,
|
.context_window_tokens,
|
||||||
131_072
|
131_072
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("openai/gpt-4.1-mini")
|
||||||
|
.expect("openai/gpt-4.1-mini should be registered")
|
||||||
|
.context_window_tokens,
|
||||||
|
1_047_576
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("gpt-5.4")
|
||||||
|
.expect("gpt-5.4 should be registered")
|
||||||
|
.context_window_tokens,
|
||||||
|
1_000_000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -728,6 +812,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_blocks_oversized_requests_for_gpt_5_4() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "gpt-5.4".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(3_900_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: Some("Keep the answer short.".to_string()),
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = preflight_message_request(&request)
|
||||||
|
.expect_err("oversized gpt-5.4 request should be rejected before the provider call");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ApiError::ContextWindowExceeded {
|
||||||
|
model,
|
||||||
|
requested_output_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(model, "gpt-5.4");
|
||||||
|
assert_eq!(requested_output_tokens, 64_000);
|
||||||
|
assert_eq!(context_window_tokens, 1_000_000);
|
||||||
|
}
|
||||||
|
other => panic!("expected context-window preflight failure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preflight_skips_unknown_models() {
|
fn preflight_skips_unknown_models() {
|
||||||
let request = MessageRequest {
|
let request = MessageRequest {
|
||||||
|
|||||||
@@ -443,6 +443,8 @@ struct StreamState {
|
|||||||
stop_reason: Option<String>,
|
stop_reason: Option<String>,
|
||||||
usage: Option<Usage>,
|
usage: Option<Usage>,
|
||||||
tool_calls: BTreeMap<u32, ToolCallState>,
|
tool_calls: BTreeMap<u32, ToolCallState>,
|
||||||
|
thinking_started: bool,
|
||||||
|
thinking_finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -456,6 +458,8 @@ impl StreamState {
|
|||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
tool_calls: BTreeMap::new(),
|
tool_calls: BTreeMap::new(),
|
||||||
|
thinking_started: false,
|
||||||
|
thinking_finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,35 +497,61 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for choice in chunk.choices {
|
for choice in chunk.choices {
|
||||||
|
if let Some(reasoning) = choice
|
||||||
|
.delta
|
||||||
|
.reasoning_content
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
if !self.thinking_started {
|
||||||
|
self.thinking_started = true;
|
||||||
|
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 0,
|
||||||
|
content_block: OutputContentBlock::Thinking {
|
||||||
|
thinking: String::new(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 0,
|
||||||
|
delta: ContentBlockDelta::ThinkingDelta {
|
||||||
|
thinking: reasoning,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
||||||
|
self.close_thinking(&mut events);
|
||||||
if !self.text_started {
|
if !self.text_started {
|
||||||
self.text_started = true;
|
self.text_started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
content_block: OutputContentBlock::Text {
|
content_block: OutputContentBlock::Text {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
delta: ContentBlockDelta::TextDelta { text: content },
|
delta: ContentBlockDelta::TextDelta { text: content },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
for tool_call in choice.delta.tool_calls {
|
for tool_call in choice.delta.tool_calls {
|
||||||
|
self.close_thinking(&mut events);
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
let state = self.tool_calls.entry(tool_call.index).or_default();
|
let state = self.tool_calls.entry(tool_call.index).or_default();
|
||||||
state.apply(tool_call);
|
state.apply(tool_call);
|
||||||
let block_index = state.block_index();
|
let block_index = state.block_index(tool_index_offset);
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(delta_event) = state.delta_event() {
|
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||||
}
|
}
|
||||||
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
|
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
|
||||||
@@ -535,11 +565,12 @@ impl StreamState {
|
|||||||
if let Some(finish_reason) = choice.finish_reason {
|
if let Some(finish_reason) = choice.finish_reason {
|
||||||
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
|
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
|
||||||
if finish_reason == "tool_calls" {
|
if finish_reason == "tool_calls" {
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
for state in self.tool_calls.values_mut() {
|
for state in self.tool_calls.values_mut() {
|
||||||
if state.started && !state.stopped {
|
if state.started && !state.stopped {
|
||||||
state.stopped = true;
|
state.stopped = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: state.block_index(),
|
index: state.block_index(tool_index_offset),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,19 +588,21 @@ impl StreamState {
|
|||||||
self.finished = true;
|
self.finished = true;
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
self.close_thinking(&mut events);
|
||||||
if self.text_started && !self.text_finished {
|
if self.text_started && !self.text_finished {
|
||||||
self.text_finished = true;
|
self.text_finished = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: 0,
|
index: self.text_block_index(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tool_index_offset = self.tool_index_offset();
|
||||||
for state in self.tool_calls.values_mut() {
|
for state in self.tool_calls.values_mut() {
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
if let Some(delta_event) = state.delta_event() {
|
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +610,7 @@ impl StreamState {
|
|||||||
if state.started && !state.stopped {
|
if state.started && !state.stopped {
|
||||||
state.stopped = true;
|
state.stopped = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: state.block_index(),
|
index: state.block_index(tool_index_offset),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +636,31 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn close_thinking(&mut self, events: &mut Vec<StreamEvent>) {
|
||||||
|
if self.thinking_started && !self.thinking_finished {
|
||||||
|
self.thinking_finished = true;
|
||||||
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
|
index: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn text_block_index(&self) -> u32 {
|
||||||
|
if self.thinking_started {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn tool_index_offset(&self) -> u32 {
|
||||||
|
if self.thinking_started {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -630,12 +688,12 @@ impl ToolCallState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn block_index(&self) -> u32 {
|
const fn block_index(&self, offset: u32) -> u32 {
|
||||||
self.openai_index + 1
|
self.openai_index + offset
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
fn start_event(&self, offset: u32) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
||||||
let Some(name) = self.name.clone() else {
|
let Some(name) = self.name.clone() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -644,7 +702,7 @@ impl ToolCallState {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
||||||
Ok(Some(ContentBlockStartEvent {
|
Ok(Some(ContentBlockStartEvent {
|
||||||
index: self.block_index(),
|
index: self.block_index(offset),
|
||||||
content_block: OutputContentBlock::ToolUse {
|
content_block: OutputContentBlock::ToolUse {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -653,14 +711,14 @@ impl ToolCallState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
fn delta_event(&mut self, offset: u32) -> Option<ContentBlockDeltaEvent> {
|
||||||
if self.emitted_len >= self.arguments.len() {
|
if self.emitted_len >= self.arguments.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let delta = self.arguments[self.emitted_len..].to_string();
|
let delta = self.arguments[self.emitted_len..].to_string();
|
||||||
self.emitted_len = self.arguments.len();
|
self.emitted_len = self.arguments.len();
|
||||||
Some(ContentBlockDeltaEvent {
|
Some(ContentBlockDeltaEvent {
|
||||||
index: self.block_index(),
|
index: self.block_index(offset),
|
||||||
delta: ContentBlockDelta::InputJsonDelta {
|
delta: ContentBlockDelta::InputJsonDelta {
|
||||||
partial_json: delta,
|
partial_json: delta,
|
||||||
},
|
},
|
||||||
@@ -690,6 +748,8 @@ struct ChatMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
tool_calls: Vec<ResponseToolCall>,
|
tool_calls: Vec<ResponseToolCall>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,6 +795,8 @@ struct ChunkChoice {
|
|||||||
struct ChunkDelta {
|
struct ChunkDelta {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||||
tool_calls: Vec<DeltaToolCall>,
|
tool_calls: Vec<DeltaToolCall>,
|
||||||
}
|
}
|
||||||
@@ -793,6 +855,15 @@ pub fn is_reasoning_model(model: &str) -> bool {
|
|||||||
|| canonical.contains("thinking")
|
|| canonical.contains("thinking")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true for OpenAI-compatible DeepSeek V4 models that require prior
|
||||||
|
/// assistant reasoning to be echoed back as `reasoning_content` in history.
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
||||||
|
let lowered = model.to_ascii_lowercase();
|
||||||
|
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
|
||||||
|
canonical.starts_with("deepseek-v4")
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||||
/// The prefix is used only to select transport; the backend expects the
|
/// The prefix is used only to select transport; the backend expects the
|
||||||
/// bare model id.
|
/// bare model id.
|
||||||
@@ -948,10 +1019,14 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
match message.role.as_str() {
|
match message.role.as_str() {
|
||||||
"assistant" => {
|
"assistant" => {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
let mut reasoning = String::new();
|
||||||
let mut tool_calls = Vec::new();
|
let mut tool_calls = Vec::new();
|
||||||
for block in &message.content {
|
for block in &message.content {
|
||||||
match block {
|
match block {
|
||||||
InputContentBlock::Text { text: value } => text.push_str(value),
|
InputContentBlock::Text { text: value } => text.push_str(value),
|
||||||
|
InputContentBlock::Thinking {
|
||||||
|
thinking: value, ..
|
||||||
|
} => reasoning.push_str(value),
|
||||||
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
|
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -963,13 +1038,18 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
InputContentBlock::ToolResult { .. } => {}
|
InputContentBlock::ToolResult { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if text.is_empty() && tool_calls.is_empty() {
|
let include_reasoning =
|
||||||
|
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
|
||||||
|
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
let mut msg = serde_json::json!({
|
let mut msg = serde_json::json!({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": (!text.is_empty()).then_some(text),
|
"content": (!text.is_empty()).then_some(text),
|
||||||
});
|
});
|
||||||
|
if include_reasoning {
|
||||||
|
msg["reasoning_content"] = json!(reasoning);
|
||||||
|
}
|
||||||
// Only include tool_calls when non-empty: some providers reject
|
// Only include tool_calls when non-empty: some providers reject
|
||||||
// assistant messages with an explicit empty tool_calls array.
|
// assistant messages with an explicit empty tool_calls array.
|
||||||
if !tool_calls.is_empty() {
|
if !tool_calls.is_empty() {
|
||||||
@@ -1003,6 +1083,7 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
Some(msg)
|
Some(msg)
|
||||||
}
|
}
|
||||||
|
InputContentBlock::Thinking { .. } => None,
|
||||||
InputContentBlock::ToolUse { .. } => None,
|
InputContentBlock::ToolUse { .. } => None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -1182,6 +1263,16 @@ fn normalize_response(
|
|||||||
"chat completion response missing choices",
|
"chat completion response missing choices",
|
||||||
))?;
|
))?;
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
|
if let Some(thinking) = choice
|
||||||
|
.message
|
||||||
|
.reasoning_content
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
content.push(OutputContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
|
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
|
||||||
content.push(OutputContentBlock::Text { text });
|
content.push(OutputContentBlock::Text { text });
|
||||||
}
|
}
|
||||||
@@ -1413,13 +1504,15 @@ impl StringExt for String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||||
normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient,
|
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||||
OpenAiCompatConfig,
|
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||||
|
StreamState,
|
||||||
};
|
};
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
|
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||||
ToolResultContentBlock,
|
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
|
||||||
|
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
@@ -1465,6 +1558,188 @@ mod tests {
|
|||||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_requires_reasoning_content_in_history_detects_deepseek_v4_models() {
|
||||||
|
// Given DeepSeek V4 and non-V4 model names.
|
||||||
|
let positive = [
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
"openai/deepseek-v4-pro",
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
];
|
||||||
|
let negative = [
|
||||||
|
"deepseek-reasoner",
|
||||||
|
"deepseek-chat",
|
||||||
|
"gpt-4o",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
];
|
||||||
|
|
||||||
|
// When checking whether history reasoning_content is required.
|
||||||
|
// Then only DeepSeek V4 variants require it.
|
||||||
|
for model in positive {
|
||||||
|
assert!(model_requires_reasoning_content_in_history(model));
|
||||||
|
}
|
||||||
|
for model in negative {
|
||||||
|
assert!(!model_requires_reasoning_content_in_history(model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_deepseek_reasoner_request_omits_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("deepseek-reasoner");
|
||||||
|
|
||||||
|
// When serializing for legacy deepseek-reasoner.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is omitted.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["role"], json!("assistant"));
|
||||||
|
assert!(assistant.get("reasoning_content").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_pro_request_includes_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("openai/deepseek-v4-pro");
|
||||||
|
|
||||||
|
// When serializing for DeepSeek V4 Pro.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is included on the assistant message.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||||
|
assert_eq!(assistant["content"], json!("answer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
||||||
|
// Given an assistant history turn containing thinking.
|
||||||
|
let request = assistant_history_with_thinking_request("deepseek-v4-flash");
|
||||||
|
|
||||||
|
// When serializing for DeepSeek V4 Flash.
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
|
||||||
|
// Then reasoning_content is included on the assistant message.
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_streaming_response_with_reasoning_content_emits_thinking_block_first() {
|
||||||
|
// Given a non-streaming OpenAI-compatible response with reasoning_content.
|
||||||
|
let response = super::ChatCompletionResponse {
|
||||||
|
id: "chatcmpl_reasoning".to_string(),
|
||||||
|
model: "deepseek-v4-pro".to_string(),
|
||||||
|
choices: vec![super::ChatChoice {
|
||||||
|
message: super::ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: Some("final answer".to_string()),
|
||||||
|
reasoning_content: Some("hidden thought".to_string()),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When normalizing the provider response.
|
||||||
|
let normalized = normalize_response("deepseek-v4-pro", response).expect("normalized");
|
||||||
|
|
||||||
|
// Then Thinking is the first content block, before text.
|
||||||
|
assert_eq!(
|
||||||
|
normalized.content,
|
||||||
|
vec![
|
||||||
|
OutputContentBlock::Thinking {
|
||||||
|
thinking: "hidden thought".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
OutputContentBlock::Text {
|
||||||
|
text: "final answer".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() {
|
||||||
|
// Given streaming chunks with reasoning_content followed by text.
|
||||||
|
let mut state = StreamState::new("deepseek-v4-pro".to_string());
|
||||||
|
let mut events = state
|
||||||
|
.ingest_chunk(super::ChatCompletionChunk {
|
||||||
|
id: "chatcmpl_stream_reasoning".to_string(),
|
||||||
|
model: Some("deepseek-v4-pro".to_string()),
|
||||||
|
choices: vec![super::ChunkChoice {
|
||||||
|
delta: super::ChunkDelta {
|
||||||
|
content: None,
|
||||||
|
reasoning_content: Some("think".to_string()),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: None,
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
.expect("reasoning chunk");
|
||||||
|
events.extend(
|
||||||
|
state
|
||||||
|
.ingest_chunk(super::ChatCompletionChunk {
|
||||||
|
id: "chatcmpl_stream_reasoning".to_string(),
|
||||||
|
model: None,
|
||||||
|
choices: vec![super::ChunkChoice {
|
||||||
|
delta: super::ChunkDelta {
|
||||||
|
content: Some(" answer".to_string()),
|
||||||
|
reasoning_content: None,
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
.expect("text chunk"),
|
||||||
|
);
|
||||||
|
events.extend(state.finish().expect("finish"));
|
||||||
|
|
||||||
|
// When reading normalized stream events.
|
||||||
|
// Then Thinking starts at index 0, text is offset to index 1.
|
||||||
|
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
||||||
|
assert!(matches!(
|
||||||
|
events[1],
|
||||||
|
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 0,
|
||||||
|
content_block: OutputContentBlock::Thinking { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[2],
|
||||||
|
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 0,
|
||||||
|
delta: ContentBlockDelta::ThinkingDelta { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[3],
|
||||||
|
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[4],
|
||||||
|
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
|
index: 1,
|
||||||
|
content_block: OutputContentBlock::Text { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[5],
|
||||||
|
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||||
|
index: 1,
|
||||||
|
delta: ContentBlockDelta::TextDelta { .. },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
events[6],
|
||||||
|
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
||||||
// OpenAI /responses endpoint rejects object schemas missing
|
// OpenAI /responses endpoint rejects object schemas missing
|
||||||
@@ -1624,6 +1899,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assistant_history_with_thinking_request(model: &str) -> MessageRequest {
|
||||||
|
MessageRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: vec![
|
||||||
|
InputContentBlock::Thinking {
|
||||||
|
thinking: "prior reasoning".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
InputContentBlock::Text {
|
||||||
|
text: "answer".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ pub enum InputContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -268,8 +273,9 @@ pub enum StreamEvent {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use runtime::format_usd;
|
use runtime::format_usd;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{MessageResponse, Usage};
|
use super::{InputContentBlock, MessageResponse, Usage};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_total_tokens_includes_cache_tokens() {
|
fn usage_total_tokens_includes_cache_tokens() {
|
||||||
@@ -307,4 +313,33 @@ mod tests {
|
|||||||
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
|
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
|
||||||
assert_eq!(response.total_tokens(), 1_800_000);
|
assert_eq!(response.total_tokens(), 1_800_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_content_block_thinking_serializes_with_snake_case_type() {
|
||||||
|
// given
|
||||||
|
let block = InputContentBlock::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig_123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// when
|
||||||
|
let serialized = serde_json::to_value(&block).unwrap();
|
||||||
|
let deserialized: InputContentBlock = serde_json::from_value(json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "pondering",
|
||||||
|
"signature": "sig_123"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
serialized,
|
||||||
|
json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "pondering",
|
||||||
|
"signature": "sig_123"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(deserialized, block);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,50 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
|
|||||||
assert_eq!(body["tools"][0]["type"], json!("function"));
|
assert_eq!(body["tools"][0]["type"], json!("function"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||||
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
let body = concat!(
|
||||||
|
"{",
|
||||||
|
"\"id\":\"chatcmpl_deepseek_reasoning\",",
|
||||||
|
"\"model\":\"deepseek-v4-pro\",",
|
||||||
|
"\"choices\":[{",
|
||||||
|
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
|
||||||
|
"\"finish_reason\":\"stop\"",
|
||||||
|
"}],",
|
||||||
|
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
let server = spawn_server(
|
||||||
|
state.clone(),
|
||||||
|
vec![http_response("200 OK", "application/json", body)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url(server.base_url());
|
||||||
|
let response = client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
model: "openai/deepseek-v4-pro".to_string(),
|
||||||
|
..sample_request(false)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.content,
|
||||||
|
vec![
|
||||||
|
OutputContentBlock::Thinking {
|
||||||
|
thinking: "Think first".to_string(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
OutputContentBlock::Text {
|
||||||
|
text: "Answer second".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
|||||||
@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
let skills = load_skills_from_roots(&roots)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report(&skills))
|
Ok(render_skills_report(&skills))
|
||||||
}
|
}
|
||||||
|
Some(args) if args.starts_with("list ") => {
|
||||||
|
let filter = args["list ".len()..].trim().to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let filtered: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report(&filtered))
|
||||||
|
}
|
||||||
|
Some("show" | "info" | "describe") => {
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report(&skills))
|
||||||
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
let name = args
|
||||||
|
.splitn(2, ' ')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let matched: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase() == name)
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report(&matched))
|
||||||
|
}
|
||||||
Some("install") => Ok(render_skills_usage(Some("install"))),
|
Some("install") => Ok(render_skills_usage(Some("install"))),
|
||||||
Some(args) if args.starts_with("install ") => {
|
Some(args) if args.starts_with("install ") => {
|
||||||
let target = args["install ".len()..].trim();
|
let target = args["install ".len()..].trim();
|
||||||
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
let skills = load_skills_from_roots(&roots)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report_json(&skills))
|
Ok(render_skills_report_json(&skills))
|
||||||
}
|
}
|
||||||
|
Some(args) if args.starts_with("list ") => {
|
||||||
|
let filter = args["list ".len()..].trim().to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let filtered: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report_json(&filtered))
|
||||||
|
}
|
||||||
|
Some("show" | "info" | "describe") => {
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report_json(&skills))
|
||||||
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
let name = args
|
||||||
|
.splitn(2, ' ')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
let matched: Vec<_> = skills
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.name.to_lowercase() == name)
|
||||||
|
.collect();
|
||||||
|
Ok(render_skills_report_json(&matched))
|
||||||
|
}
|
||||||
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
||||||
Some(args) if args.starts_with("install ") => {
|
Some(args) if args.starts_with("install ") => {
|
||||||
let target = args["install ".len()..].trim();
|
let target = args["install ".len()..].trim();
|
||||||
@@ -2419,10 +2487,20 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
||||||
match normalize_optional_args(args) {
|
match normalize_optional_args(args) {
|
||||||
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
|
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
|
||||||
|
SkillSlashDispatch::Local
|
||||||
|
}
|
||||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||||
SkillSlashDispatch::Local
|
SkillSlashDispatch::Local
|
||||||
}
|
}
|
||||||
|
Some(args)
|
||||||
|
if args.starts_with("list ")
|
||||||
|
|| args.starts_with("show ")
|
||||||
|
|| args.starts_with("info ")
|
||||||
|
|| args.starts_with("describe ") =>
|
||||||
|
{
|
||||||
|
SkillSlashDispatch::Local
|
||||||
|
}
|
||||||
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2596,10 +2674,44 @@ fn render_mcp_report_for(
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||||
|
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
|
||||||
|
Ok(render_mcp_unsupported_action_text(
|
||||||
|
args,
|
||||||
|
"list accepts no filter argument; use `claw mcp list`",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||||
|
Ok(render_mcp_unsupported_action_text(
|
||||||
|
args,
|
||||||
|
"use `claw mcp show <server>` to inspect a server",
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(args) => Ok(render_mcp_usage(Some(args))),
|
Some(args) => Ok(render_mcp_usage(Some(args))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"kind": "mcp",
|
||||||
|
"action": "error",
|
||||||
|
"ok": false,
|
||||||
|
"error_kind": "unsupported_action",
|
||||||
|
"requested_action": action,
|
||||||
|
"hint": hint,
|
||||||
|
"usage": {
|
||||||
|
"slash_command": "/mcp [list|show <server>|help]",
|
||||||
|
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn render_mcp_report_json_for(
|
fn render_mcp_report_json_for(
|
||||||
loader: &ConfigLoader,
|
loader: &ConfigLoader,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
@@ -2680,6 +2792,18 @@ fn render_mcp_report_json_for(
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||||
|
Ok(render_mcp_unsupported_action_json(
|
||||||
|
args,
|
||||||
|
"list accepts no filter argument; use `claw mcp list`",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||||
|
Ok(render_mcp_unsupported_action_json(
|
||||||
|
args,
|
||||||
|
"use `claw mcp show <server>` to inspect a server",
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4619,6 +4743,32 @@ mod tests {
|
|||||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_show_and_list_filter_do_not_invoke_model() {
|
||||||
|
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
|
||||||
|
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
|
||||||
|
for token in &["show", "info", "describe"] {
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some(token)),
|
||||||
|
SkillSlashDispatch::Local,
|
||||||
|
"`skills {token}` alone must be Local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for prefix in &["show ", "info ", "list ", "describe "] {
|
||||||
|
let arg = format!("{prefix}plan");
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some(&arg)),
|
||||||
|
SkillSlashDispatch::Local,
|
||||||
|
"`skills {arg}` must be Local, not Invoke"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Bare invocable tokens still dispatch to Invoke.
|
||||||
|
assert_eq!(
|
||||||
|
classify_skills_slash_command(Some("plan")),
|
||||||
|
SkillSlashDispatch::Invoke("$plan".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -4641,6 +4791,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
|
||||||
|
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
|
||||||
|
// Regression for #504: these previously fell through to render_mcp_usage with
|
||||||
|
// unexpected=arg, giving no machine-readable error_kind.
|
||||||
|
use crate::handle_mcp_slash_command_json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
let cwd = PathBuf::from("/tmp");
|
||||||
|
|
||||||
|
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
|
||||||
|
.expect("info nonexistent should not error at IO level");
|
||||||
|
assert_eq!(info_json["kind"], "mcp");
|
||||||
|
assert_eq!(info_json["ok"], false);
|
||||||
|
assert_eq!(info_json["error_kind"], "unsupported_action");
|
||||||
|
assert!(info_json["hint"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("show"));
|
||||||
|
|
||||||
|
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
|
||||||
|
.expect("list nonexistent should not error at IO level");
|
||||||
|
assert_eq!(list_filter_json["kind"], "mcp");
|
||||||
|
assert_eq!(list_filter_json["ok"], false);
|
||||||
|
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
|
||||||
|
|
||||||
|
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
|
||||||
|
.expect("describe myserver should not error at IO level");
|
||||||
|
assert_eq!(describe_json["kind"], "mcp");
|
||||||
|
assert_eq!(describe_json["ok"], false);
|
||||||
|
assert_eq!(describe_json["error_kind"], "unsupported_action");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_mcp_arguments() {
|
fn rejects_invalid_mcp_arguments() {
|
||||||
let show_error = parse_error_message("/mcp show alpha beta");
|
let show_error = parse_error_message("/mcp show alpha beta");
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ fn detect_scenario(request: &MessageRequest) -> Option<Scenario> {
|
|||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
|
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
|
||||||
.and_then(Scenario::parse),
|
.and_then(Scenario::parse),
|
||||||
|
InputContentBlock::Thinking { .. } => None,
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
|
|||||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||||
pr_number: None,
|
pr_number: None,
|
||||||
};
|
};
|
||||||
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
|
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
|
||||||
// Log to stderr as interim routing before event stream integration
|
// Log to stderr as interim routing before event stream integration
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||||
ContentBlock::Text { .. } => None,
|
ContentBlock::Text { .. } => None,
|
||||||
|
ContentBlock::Thinking { .. } => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
tool_names.sort_unstable();
|
tool_names.sort_unstable();
|
||||||
@@ -317,6 +318,9 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) ->
|
|||||||
fn summarize_block(block: &ContentBlock) -> String {
|
fn summarize_block(block: &ContentBlock) -> String {
|
||||||
let raw = match block {
|
let raw = match block {
|
||||||
ContentBlock::Text { text } => text.clone(),
|
ContentBlock::Text { text } => text.clone(),
|
||||||
|
ContentBlock::Thinking { thinking, .. } => {
|
||||||
|
format!("thinking ({} chars)", thinking.chars().count())
|
||||||
|
}
|
||||||
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name,
|
tool_name,
|
||||||
@@ -378,6 +382,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
|||||||
ContentBlock::Text { text } => text.as_str(),
|
ContentBlock::Text { text } => text.as_str(),
|
||||||
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||||
|
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
|
||||||
})
|
})
|
||||||
.flat_map(extract_file_candidates)
|
.flat_map(extract_file_candidates)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -400,6 +405,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
|||||||
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||||
ContentBlock::ToolUse { .. }
|
ContentBlock::ToolUse { .. }
|
||||||
| ContentBlock::ToolResult { .. }
|
| ContentBlock::ToolResult { .. }
|
||||||
|
| ContentBlock::Thinking { .. }
|
||||||
| ContentBlock::Text { .. } => None,
|
| ContentBlock::Text { .. } => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -450,6 +456,10 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
|||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name, output, ..
|
tool_name, output, ..
|
||||||
} => (tool_name.len() + output.len()) / 4 + 1,
|
} => (tool_name.len() + output.len()) / 4 + 1,
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => thinking.len() / 4 + signature.as_ref().map_or(0, |value| value.len() / 4 + 1),
|
||||||
})
|
})
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub struct ApiRequest {
|
|||||||
/// Streamed events emitted while processing a single assistant turn.
|
/// Streamed events emitted while processing a single assistant turn.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AssistantEvent {
|
pub enum AssistantEvent {
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
TextDelta(String),
|
TextDelta(String),
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -721,6 +725,16 @@ fn build_assistant_message(
|
|||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
match event {
|
||||||
|
AssistantEvent::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
flush_text_block(&mut text, &mut blocks);
|
||||||
|
blocks.push(ContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
}
|
||||||
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
||||||
AssistantEvent::ToolUse { id, name, input } => {
|
AssistantEvent::ToolUse { id, name, input } => {
|
||||||
flush_text_block(&mut text, &mut blocks);
|
flush_text_block(&mut text, &mut blocks);
|
||||||
@@ -1723,6 +1737,47 @@ mod tests {
|
|||||||
.contains("assistant stream produced no content"));
|
.contains("assistant stream produced no content"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_assistant_message_places_thinking_block_before_text_and_tool_use() {
|
||||||
|
// given
|
||||||
|
let events = vec![
|
||||||
|
AssistantEvent::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig".to_string()),
|
||||||
|
},
|
||||||
|
AssistantEvent::TextDelta("hello".to_string()),
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "echo".to_string(),
|
||||||
|
input: "payload".to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
];
|
||||||
|
|
||||||
|
// when
|
||||||
|
let (message, _, _) = build_assistant_message(events)
|
||||||
|
.expect("assistant message should preserve thinking, text, and tool blocks");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
message.blocks,
|
||||||
|
vec![
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "pondering".to_string(),
|
||||||
|
signature: Some("sig".to_string()),
|
||||||
|
},
|
||||||
|
ContentBlock::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
|
ContentBlock::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "echo".to_string(),
|
||||||
|
input: "payload".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn static_tool_executor_rejects_unknown_tools() {
|
fn static_tool_executor_rejects_unknown_tools() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -7,7 +8,7 @@ use std::time::Instant;
|
|||||||
use glob::Pattern;
|
use glob::Pattern;
|
||||||
use regex::RegexBuilder;
|
use regex::RegexBuilder;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use walkdir::WalkDir;
|
use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
/// Maximum file size that can be read (10 MB).
|
/// Maximum file size that can be read (10 MB).
|
||||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
@@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
|||||||
/// Maximum file size that can be written (10 MB).
|
/// Maximum file size that can be written (10 MB).
|
||||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
".build",
|
||||||
|
"target",
|
||||||
|
"dist",
|
||||||
|
"coverage",
|
||||||
|
];
|
||||||
|
|
||||||
/// Check whether a file appears to contain binary content by examining
|
/// Check whether a file appears to contain binary content by examining
|
||||||
/// the first chunk for NUL bytes.
|
/// the first chunk for NUL bytes.
|
||||||
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||||
@@ -313,14 +323,22 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
|||||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
||||||
let expanded = expand_braces(&search_pattern);
|
let expanded = expand_braces(&search_pattern);
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
for pat in &expanded {
|
for pat in &expanded {
|
||||||
let entries = glob::glob(pat)
|
let compiled = Pattern::new(pat)
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||||
|
let walk_root = derive_glob_walk_root(pat);
|
||||||
|
let entries = WalkDir::new(&walk_root)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|entry| !should_skip_glob_dir(entry));
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.is_file() && seen.insert(entry.clone()) {
|
let candidate = entry.path();
|
||||||
matches.push(entry);
|
if entry.file_type().is_file()
|
||||||
|
&& compiled.matches_path(candidate)
|
||||||
|
&& seen.insert(candidate.to_path_buf())
|
||||||
|
{
|
||||||
|
matches.push(candidate.to_path_buf());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,6 +475,39 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
|
||||||
|
entry.file_type().is_dir()
|
||||||
|
&& entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_glob_walk_root(pattern: &str) -> PathBuf {
|
||||||
|
let path = Path::new(pattern);
|
||||||
|
let mut prefix = PathBuf::new();
|
||||||
|
let mut saw_component = false;
|
||||||
|
|
||||||
|
for component in path.components() {
|
||||||
|
let text = component.as_os_str().to_string_lossy();
|
||||||
|
if component_contains_glob(&text) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prefix.push(component.as_os_str());
|
||||||
|
saw_component = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if saw_component {
|
||||||
|
prefix
|
||||||
|
} else {
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_contains_glob(component: &str) -> bool {
|
||||||
|
component.contains('*') || component.contains('?') || component.contains('[')
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||||
if base_path.is_file() {
|
if base_path.is_file() {
|
||||||
return Ok(vec![base_path.to_path_buf()]);
|
return Ok(vec![base_path.to_path_buf()]);
|
||||||
@@ -651,11 +702,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
|
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
|
||||||
|
GrepSearchInput, MAX_WRITE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||||
@@ -836,4 +889,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_search_skips_common_heavy_directories() {
|
||||||
|
let dir = temp_path("glob-ignored-dirs");
|
||||||
|
std::fs::create_dir_all(dir.join("src")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("docs")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap();
|
||||||
|
std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap();
|
||||||
|
std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap();
|
||||||
|
std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap();
|
||||||
|
std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap();
|
||||||
|
|
||||||
|
let result =
|
||||||
|
glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.num_files, 2, "ignored dirs should be pruned");
|
||||||
|
assert!(result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.ends_with("src/AGENTS.md")));
|
||||||
|
assert!(result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.ends_with("docs/AGENTS.md")));
|
||||||
|
assert!(!result
|
||||||
|
.filenames
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.contains("node_modules")
|
||||||
|
|| path.contains(".build")
|
||||||
|
|| path.contains("/target/")));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_glob_walk_root_stops_at_first_glob_component() {
|
||||||
|
let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md");
|
||||||
|
assert_eq!(root, PathBuf::from("/tmp/demo"));
|
||||||
|
assert!(component_contains_glob("**"));
|
||||||
|
assert!(component_contains_glob("*.rs"));
|
||||||
|
assert!(!component_contains_glob("src"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -131,8 +131,8 @@ pub use policy_engine::{
|
|||||||
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
||||||
};
|
};
|
||||||
pub use prompt::{
|
pub use prompt::{
|
||||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||||
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
};
|
};
|
||||||
pub use recovery_recipes::{
|
pub use recovery_recipes::{
|
||||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
|
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
|||||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||||
|
|
||||||
|
/// Neutral identity for the model family line in generated prompts.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum ModelFamilyIdentity {
|
||||||
|
#[default]
|
||||||
|
Claude,
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelFamilyIdentity {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn family_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Claude => FRONTIER_MODEL_NAME,
|
||||||
|
Self::Generic => "an AI assistant",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Contents of an instruction file included in prompt construction.
|
/// Contents of an instruction file included in prompt construction.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ContextFile {
|
pub struct ContextFile {
|
||||||
@@ -97,6 +115,7 @@ pub struct SystemPromptBuilder {
|
|||||||
output_style_prompt: Option<String>,
|
output_style_prompt: Option<String>,
|
||||||
os_name: Option<String>,
|
os_name: Option<String>,
|
||||||
os_version: Option<String>,
|
os_version: Option<String>,
|
||||||
|
model_family: Option<ModelFamilyIdentity>,
|
||||||
append_sections: Vec<String>,
|
append_sections: Vec<String>,
|
||||||
project_context: Option<ProjectContext>,
|
project_context: Option<ProjectContext>,
|
||||||
config: Option<RuntimeConfig>,
|
config: Option<RuntimeConfig>,
|
||||||
@@ -122,6 +141,12 @@ impl SystemPromptBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_model_family(mut self, model_family: ModelFamilyIdentity) -> Self {
|
||||||
|
self.model_family = Some(model_family);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
||||||
self.project_context = Some(project_context);
|
self.project_context = Some(project_context);
|
||||||
@@ -179,9 +204,10 @@ impl SystemPromptBuilder {
|
|||||||
|| "unknown".to_string(),
|
|| "unknown".to_string(),
|
||||||
|context| context.current_date.clone(),
|
|context| context.current_date.clone(),
|
||||||
);
|
);
|
||||||
|
let identity = self.model_family.unwrap_or_default();
|
||||||
let mut lines = vec!["# Environment context".to_string()];
|
let mut lines = vec!["# Environment context".to_string()];
|
||||||
lines.extend(prepend_bullets(vec![
|
lines.extend(prepend_bullets(vec![
|
||||||
format!("Model family: {FRONTIER_MODEL_NAME}"),
|
format!("Model family: {}", identity.family_label()),
|
||||||
format!("Working directory: {cwd}"),
|
format!("Working directory: {cwd}"),
|
||||||
format!("Date: {date}"),
|
format!("Date: {date}"),
|
||||||
format!(
|
format!(
|
||||||
@@ -434,12 +460,14 @@ pub fn load_system_prompt(
|
|||||||
current_date: impl Into<String>,
|
current_date: impl Into<String>,
|
||||||
os_name: impl Into<String>,
|
os_name: impl Into<String>,
|
||||||
os_version: impl Into<String>,
|
os_version: impl Into<String>,
|
||||||
|
model_family: ModelFamilyIdentity,
|
||||||
) -> Result<Vec<String>, PromptBuildError> {
|
) -> Result<Vec<String>, PromptBuildError> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||||
Ok(SystemPromptBuilder::new()
|
Ok(SystemPromptBuilder::new()
|
||||||
.with_os(os_name, os_version)
|
.with_os(os_name, os_version)
|
||||||
|
.with_model_family(model_family)
|
||||||
.with_project_context(project_context)
|
.with_project_context(project_context)
|
||||||
.with_runtime_config(config)
|
.with_runtime_config(config)
|
||||||
.build())
|
.build())
|
||||||
@@ -522,7 +550,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||||
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
||||||
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder,
|
||||||
|
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
};
|
};
|
||||||
use crate::config::ConfigLoader;
|
use crate::config::ConfigLoader;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -804,13 +833,19 @@ mod tests {
|
|||||||
std::env::set_var("HOME", &root);
|
std::env::set_var("HOME", &root);
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||||
std::env::set_current_dir(&root).expect("change cwd");
|
std::env::set_current_dir(&root).expect("change cwd");
|
||||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
let prompt = super::load_system_prompt(
|
||||||
.expect("system prompt should load")
|
&root,
|
||||||
.join(
|
"2026-03-31",
|
||||||
"
|
"linux",
|
||||||
|
"6.8",
|
||||||
|
ModelFamilyIdentity::Claude,
|
||||||
|
)
|
||||||
|
.expect("system prompt should load")
|
||||||
|
.join(
|
||||||
|
"
|
||||||
|
|
||||||
",
|
",
|
||||||
);
|
);
|
||||||
std::env::set_current_dir(previous).expect("restore cwd");
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
if let Some(value) = original_home {
|
if let Some(value) = original_home {
|
||||||
std::env::set_var("HOME", value);
|
std::env::set_var("HOME", value);
|
||||||
@@ -828,6 +863,50 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_default_claude_model_family_identity() {
|
||||||
|
// given: a prompt builder without an explicit model family override
|
||||||
|
let project_context = ProjectContext {
|
||||||
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
|
current_date: "2026-03-31".to_string(),
|
||||||
|
..ProjectContext::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// when: rendering the system prompt environment section
|
||||||
|
let prompt = SystemPromptBuilder::new()
|
||||||
|
.with_os("linux", "6.8")
|
||||||
|
.with_project_context(project_context)
|
||||||
|
.render();
|
||||||
|
|
||||||
|
// then: the Claude model family label is preserved by default
|
||||||
|
assert!(prompt.contains("Model family: Claude Opus 4.6"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_generic_model_family_identity_without_claude_label() {
|
||||||
|
// given: a prompt builder with generic model family identity
|
||||||
|
let project_context = ProjectContext {
|
||||||
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
|
current_date: "2026-03-31".to_string(),
|
||||||
|
..ProjectContext::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// when: rendering the system prompt environment section
|
||||||
|
let prompt = SystemPromptBuilder::new()
|
||||||
|
.with_os("linux", "6.8")
|
||||||
|
.with_model_family(ModelFamilyIdentity::Generic)
|
||||||
|
.with_project_context(project_context)
|
||||||
|
.render();
|
||||||
|
let model_family_line = prompt
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.contains("Model family:"))
|
||||||
|
.expect("model family line should render");
|
||||||
|
|
||||||
|
// then: the model family line is neutral and excludes Claude Opus 4.6
|
||||||
|
assert_eq!(model_family_line, " - Model family: an AI assistant");
|
||||||
|
assert!(!model_family_line.contains("Claude Opus 4.6"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_claude_code_style_sections_with_project_context() {
|
fn renders_claude_code_style_sections_with_project_context() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ impl FailureScenario {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
||||||
match kind {
|
match kind {
|
||||||
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
|
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => {
|
||||||
|
Self::TrustPromptUnresolved
|
||||||
|
}
|
||||||
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
||||||
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
||||||
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ pub enum ContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -737,6 +741,22 @@ impl ContentBlock {
|
|||||||
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
||||||
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
||||||
}
|
}
|
||||||
|
Self::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
object.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
JsonValue::String("thinking".to_string()),
|
||||||
|
);
|
||||||
|
object.insert("thinking".to_string(), JsonValue::String(thinking.clone()));
|
||||||
|
if let Some(signature) = signature {
|
||||||
|
object.insert(
|
||||||
|
"signature".to_string(),
|
||||||
|
JsonValue::String(signature.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Self::ToolUse { id, name, input } => {
|
Self::ToolUse { id, name, input } => {
|
||||||
object.insert(
|
object.insert(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
@@ -783,6 +803,13 @@ impl ContentBlock {
|
|||||||
"text" => Ok(Self::Text {
|
"text" => Ok(Self::Text {
|
||||||
text: required_string(object, "text")?,
|
text: required_string(object, "text")?,
|
||||||
}),
|
}),
|
||||||
|
"thinking" => Ok(Self::Thinking {
|
||||||
|
thinking: required_string(object, "thinking")?,
|
||||||
|
signature: object
|
||||||
|
.get("signature")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(String::from),
|
||||||
|
}),
|
||||||
"tool_use" => Ok(Self::ToolUse {
|
"tool_use" => Ok(Self::ToolUse {
|
||||||
id: required_string(object, "id")?,
|
id: required_string(object, "id")?,
|
||||||
name: required_string(object, "name")?,
|
name: required_string(object, "name")?,
|
||||||
@@ -1208,6 +1235,36 @@ mod tests {
|
|||||||
assert_eq!(restored.session_id, session.session_id);
|
assert_eq!(restored.session_id, session.session_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persists_assistant_thinking_block_round_trip_through_jsonl() {
|
||||||
|
// given
|
||||||
|
let mut session = Session::new();
|
||||||
|
session
|
||||||
|
.push_message(ConversationMessage::assistant(vec![
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "trace the path through session persistence".to_string(),
|
||||||
|
signature: Some("sig-123".to_string()),
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
.expect("thinking block should append");
|
||||||
|
let path = temp_session_path("thinking-jsonl");
|
||||||
|
|
||||||
|
// when
|
||||||
|
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");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(restored, session);
|
||||||
|
assert_eq!(
|
||||||
|
restored.messages[0].blocks[0],
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking: "trace the path through session persistence".to_string(),
|
||||||
|
signature: Some("sig-123".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_legacy_session_json_object() {
|
fn loads_legacy_session_json_object() {
|
||||||
let path = temp_session_path("legacy");
|
let path = temp_session_path("legacy");
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const TRUST_PROMPT_CUES: &[&str] = &[
|
const TRUST_PROMPT_CUES: &[&str] = &[
|
||||||
"do you trust the files in this folder",
|
"do you trust the files in this folder",
|
||||||
"trust the files in this folder",
|
"trust the files in this folder",
|
||||||
@@ -8,24 +10,121 @@ const TRUST_PROMPT_CUES: &[&str] = &[
|
|||||||
"yes, proceed",
|
"yes, proceed",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Resolution method for trust decisions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TrustPolicy {
|
pub enum TrustPolicy {
|
||||||
|
/// Automatically trust this path (allowlisted)
|
||||||
AutoTrust,
|
AutoTrust,
|
||||||
|
/// Require manual approval
|
||||||
RequireApproval,
|
RequireApproval,
|
||||||
|
/// Deny trust for this path
|
||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// Events emitted during trust resolution lifecycle.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum TrustEvent {
|
pub enum TrustEvent {
|
||||||
TrustRequired { cwd: String },
|
/// Trust prompt was detected and is required
|
||||||
TrustResolved { cwd: String, policy: TrustPolicy },
|
TrustRequired {
|
||||||
TrustDenied { cwd: String, reason: String },
|
/// Current working directory where trust is needed
|
||||||
|
cwd: String,
|
||||||
|
/// Optional repo identifier
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// Optional worktree path
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
worktree: Option<String>,
|
||||||
|
},
|
||||||
|
/// Trust was resolved (granted)
|
||||||
|
TrustResolved {
|
||||||
|
/// Current working directory
|
||||||
|
cwd: String,
|
||||||
|
/// The policy that was applied
|
||||||
|
policy: TrustPolicy,
|
||||||
|
/// How the trust was resolved
|
||||||
|
resolution: TrustResolution,
|
||||||
|
},
|
||||||
|
/// Trust was denied
|
||||||
|
TrustDenied {
|
||||||
|
/// Current working directory
|
||||||
|
cwd: String,
|
||||||
|
/// Reason for denial
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
/// How trust was resolved.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TrustResolution {
|
||||||
|
/// Automatically granted due to allowlist
|
||||||
|
AutoAllowlisted,
|
||||||
|
/// Manually approved by user
|
||||||
|
ManualApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry in the trust allowlist with pattern matching support.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TrustAllowlistEntry {
|
||||||
|
/// Repository path or glob pattern to match
|
||||||
|
pub pattern: String,
|
||||||
|
/// Optional worktree subpath pattern
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worktree_pattern: Option<String>,
|
||||||
|
/// Human-readable description of why this is allowlisted
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustAllowlistEntry {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pattern: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
pattern: pattern.into(),
|
||||||
|
worktree_pattern: None,
|
||||||
|
description: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
|
||||||
|
self.worktree_pattern = Some(pattern.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||||
|
self.description = Some(desc.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for trust resolution with allowlist/denylist support.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TrustConfig {
|
pub struct TrustConfig {
|
||||||
allowlisted: Vec<PathBuf>,
|
/// Allowlisted paths with pattern matching
|
||||||
denied: Vec<PathBuf>,
|
pub allowlisted: Vec<TrustAllowlistEntry>,
|
||||||
|
/// Denied paths (exact or prefix matches)
|
||||||
|
pub denied: Vec<PathBuf>,
|
||||||
|
/// Whether to emit events for trust decisions
|
||||||
|
#[serde(default = "default_emit_events")]
|
||||||
|
pub emit_events: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_emit_events() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TrustConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allowlisted: Vec::new(),
|
||||||
|
denied: Vec::new(),
|
||||||
|
emit_events: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrustConfig {
|
impl TrustConfig {
|
||||||
@@ -35,8 +134,14 @@ impl TrustConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self {
|
||||||
self.allowlisted.push(path.into());
|
self.allowlisted.push(TrustAllowlistEntry::new(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
|
||||||
|
self.allowlisted.push(entry);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +150,147 @@ impl TrustConfig {
|
|||||||
self.denied.push(path.into());
|
self.denied.push(path.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a path matches an allowlisted entry using glob patterns.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_allowlisted(
|
||||||
|
&self,
|
||||||
|
cwd: &str,
|
||||||
|
worktree: Option<&str>,
|
||||||
|
) -> Option<&TrustAllowlistEntry> {
|
||||||
|
self.allowlisted.iter().find(|entry| {
|
||||||
|
let path_matches = Self::pattern_matches(&entry.pattern, cwd);
|
||||||
|
if !path_matches {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (&entry.worktree_pattern, worktree) {
|
||||||
|
(Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
(None, _) => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match a pattern against a path string.
|
||||||
|
/// Supports exact matching and glob patterns (* and ?).
|
||||||
|
fn pattern_matches(pattern: &str, path: &str) -> bool {
|
||||||
|
let pattern = pattern.trim();
|
||||||
|
let path = path.trim();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if pattern == path {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize paths for comparison
|
||||||
|
let pattern_normalized = pattern.replace("//", "/");
|
||||||
|
let path_normalized = path.replace("//", "/");
|
||||||
|
|
||||||
|
// Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a")
|
||||||
|
// This handles the common case of directory containment
|
||||||
|
if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') {
|
||||||
|
// Prefix match: pattern is a directory that contains path
|
||||||
|
if path_normalized.starts_with(&pattern_normalized) {
|
||||||
|
let rest = &path_normalized[pattern_normalized.len()..];
|
||||||
|
// Must be exact match or continue with /
|
||||||
|
return rest.is_empty() || rest.starts_with('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern ends with wildcard (prefix match)
|
||||||
|
if pattern_normalized.ends_with("/*") {
|
||||||
|
let prefix = pattern_normalized.trim_end_matches("/*");
|
||||||
|
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||||
|
// Must either be exact match or continue with /
|
||||||
|
return rest.is_empty() || rest.starts_with('/');
|
||||||
|
}
|
||||||
|
} else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") {
|
||||||
|
// Simple trailing * (not a path component wildcard)
|
||||||
|
let prefix = pattern_normalized.trim_end_matches('*');
|
||||||
|
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||||
|
return rest.is_empty() || !rest.starts_with('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern is a path component match (bounded by /)
|
||||||
|
if path_normalized
|
||||||
|
.split('/')
|
||||||
|
.any(|component| component == pattern_normalized)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern appears as a substring within a path component
|
||||||
|
// (e.g., "repo" matches "/tmp/worktrees/repo-a")
|
||||||
|
if path_normalized
|
||||||
|
.split('/')
|
||||||
|
.any(|component| component.contains(&pattern_normalized))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glob matching for patterns with ? or * in the middle
|
||||||
|
if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") {
|
||||||
|
return Self::glob_matches(&pattern_normalized, &path_normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple glob pattern matching (? matches single char, * matches any sequence).
|
||||||
|
/// Handles patterns like /tmp/*/repo-* where * matches path components.
|
||||||
|
fn glob_matches(pattern: &str, path: &str) -> bool {
|
||||||
|
// Use recursive backtracking for proper glob matching
|
||||||
|
Self::glob_match_recursive(pattern, path, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool {
|
||||||
|
let p_chars: Vec<char> = pattern.chars().collect();
|
||||||
|
let s_chars: Vec<char> = path.chars().collect();
|
||||||
|
|
||||||
|
let mut p = p_idx;
|
||||||
|
let mut s = s_idx;
|
||||||
|
|
||||||
|
while p < p_chars.len() {
|
||||||
|
match p_chars[p] {
|
||||||
|
'*' => {
|
||||||
|
// Try all possible matches for *
|
||||||
|
p += 1;
|
||||||
|
if p >= p_chars.len() {
|
||||||
|
// * at end matches everything remaining
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Try matching 0 or more characters
|
||||||
|
for skip in 0..=(s_chars.len() - s) {
|
||||||
|
if Self::glob_match_recursive(pattern, path, p, s + skip) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
'?' => {
|
||||||
|
// ? matches exactly one character
|
||||||
|
if s >= s_chars.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p += 1;
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
c => {
|
||||||
|
// Exact character match
|
||||||
|
if s >= s_chars.len() || s_chars[s] != c {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p += 1;
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern exhausted - path must also be exhausted
|
||||||
|
s >= s_chars.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -86,15 +332,19 @@ impl TrustResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
|
pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision {
|
||||||
if !detect_trust_prompt(screen_text) {
|
if !detect_trust_prompt(screen_text) {
|
||||||
return TrustDecision::NotRequired;
|
return TrustDecision::NotRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let repo = extract_repo_name(cwd);
|
||||||
let mut events = vec![TrustEvent::TrustRequired {
|
let mut events = vec![TrustEvent::TrustRequired {
|
||||||
cwd: cwd.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
|
repo: repo.clone(),
|
||||||
|
worktree: worktree.map(String::from),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
// Check denylist first
|
||||||
if let Some(matched_root) = self
|
if let Some(matched_root) = self
|
||||||
.config
|
.config
|
||||||
.denied
|
.denied
|
||||||
@@ -112,15 +362,12 @@ impl TrustResolver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if self
|
// Check allowlist with pattern matching
|
||||||
.config
|
if self.config.is_allowlisted(cwd, worktree).is_some() {
|
||||||
.allowlisted
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
{
|
|
||||||
events.push(TrustEvent::TrustResolved {
|
events.push(TrustEvent::TrustResolved {
|
||||||
cwd: cwd.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
});
|
});
|
||||||
return TrustDecision::Required {
|
return TrustDecision::Required {
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
@@ -128,6 +375,19 @@ impl TrustResolver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for manual trust resolution via screen text analysis
|
||||||
|
if detect_manual_approval(screen_text) {
|
||||||
|
events.push(TrustEvent::TrustResolved {
|
||||||
|
cwd: cwd.to_owned(),
|
||||||
|
policy: TrustPolicy::RequireApproval,
|
||||||
|
resolution: TrustResolution::ManualApproval,
|
||||||
|
});
|
||||||
|
return TrustDecision::Required {
|
||||||
|
policy: TrustPolicy::RequireApproval,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
TrustDecision::Required {
|
TrustDecision::Required {
|
||||||
policy: TrustPolicy::RequireApproval,
|
policy: TrustPolicy::RequireApproval,
|
||||||
events,
|
events,
|
||||||
@@ -135,17 +395,20 @@ impl TrustResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn trusts(&self, cwd: &str) -> bool {
|
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool {
|
||||||
!self
|
// Check denylist first
|
||||||
|
let denied = self
|
||||||
.config
|
.config
|
||||||
.denied
|
.denied
|
||||||
.iter()
|
.iter()
|
||||||
.any(|root| path_matches(cwd, root))
|
.any(|root| path_matches(cwd, root));
|
||||||
&& self
|
|
||||||
.config
|
if denied {
|
||||||
.allowlisted
|
return false;
|
||||||
.iter()
|
}
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
|
// Check allowlist using pattern matching
|
||||||
|
self.config.is_allowlisted(cwd, worktree).is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +435,240 @@ fn normalize_path(path: &Path) -> PathBuf {
|
|||||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract repository name from a path for event context.
|
||||||
|
fn extract_repo_name(cwd: &str) -> Option<String> {
|
||||||
|
let path = Path::new(cwd);
|
||||||
|
// Try to find a .git directory to identify repo root
|
||||||
|
let mut current = Some(path);
|
||||||
|
while let Some(p) = current {
|
||||||
|
if p.join(".git").is_dir() {
|
||||||
|
return p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
current = p.parent();
|
||||||
|
}
|
||||||
|
// Fallback: use the last component of the path
|
||||||
|
path.file_name().map(|n| n.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if the screen text indicates manual approval was granted.
|
||||||
|
fn detect_manual_approval(screen_text: &str) -> bool {
|
||||||
|
let lowered = screen_text.to_ascii_lowercase();
|
||||||
|
// Look for indicators that user manually approved
|
||||||
|
MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANUAL_APPROVAL_CUES: &[&str] = &[
|
||||||
|
"yes, i trust",
|
||||||
|
"i trust this",
|
||||||
|
"trusted manually",
|
||||||
|
"approval granted",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod path_matching_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_pattern_star_matches_any_sequence() {
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo"));
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_pattern_question_matches_single_char() {
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1"));
|
||||||
|
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12"));
|
||||||
|
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_exact() {
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees",
|
||||||
|
"/tmp/worktrees"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees",
|
||||||
|
"/tmp/worktrees-other"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_prefix_with_wildcard() {
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/worktrees/repo-a/subdir"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/worktrees/*",
|
||||||
|
"/tmp/other/repo"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_matches_contains() {
|
||||||
|
// Pattern contained within path
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"worktrees",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"repo",
|
||||||
|
"/tmp/worktrees/repo-a"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlist_entry_with_worktree_pattern() {
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||||
|
.with_worktree_pattern("*/.git")
|
||||||
|
.with_description("Git worktrees"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should match when both patterns match
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"))
|
||||||
|
.is_some());
|
||||||
|
|
||||||
|
// Should not match when worktree pattern doesn't match
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path"))
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Should not match when a worktree pattern is required but no worktree is supplied
|
||||||
|
assert!(config
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Should match when no worktree pattern required and path matches
|
||||||
|
let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*");
|
||||||
|
assert!(config_no_worktree
|
||||||
|
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlist_entry_returns_matched_entry() {
|
||||||
|
let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees");
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(entry.clone());
|
||||||
|
|
||||||
|
let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None);
|
||||||
|
assert!(matched.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
matched.unwrap().description,
|
||||||
|
Some("Test worktrees".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complex_glob_patterns() {
|
||||||
|
// Multiple wildcards
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/worktrees/repo-123"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/other/repo-abc"
|
||||||
|
));
|
||||||
|
assert!(!TrustConfig::pattern_matches(
|
||||||
|
"/tmp/*/repo-*",
|
||||||
|
"/tmp/worktrees/other"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Mixed ? and *
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/test?/*.txt",
|
||||||
|
"/tmp/test1/file.txt"
|
||||||
|
));
|
||||||
|
assert!(TrustConfig::pattern_matches(
|
||||||
|
"/tmp/test?/*.txt",
|
||||||
|
"/tmp/testA/subdir/file.txt"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_serialization_roundtrip() {
|
||||||
|
let config = TrustConfig::new()
|
||||||
|
.with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||||
|
.with_worktree_pattern("*/.git")
|
||||||
|
.with_description("Git worktrees"),
|
||||||
|
)
|
||||||
|
.with_denied("/tmp/malicious");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).expect("serialization failed");
|
||||||
|
let deserialized: TrustConfig =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
|
||||||
|
assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len());
|
||||||
|
assert_eq!(config.denied.len(), deserialized.denied.len());
|
||||||
|
assert_eq!(config.emit_events, deserialized.emit_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_event_serialization() {
|
||||||
|
let event = TrustEvent::TrustRequired {
|
||||||
|
cwd: "/tmp/test".to_string(),
|
||||||
|
repo: Some("test-repo".to_string()),
|
||||||
|
worktree: Some("/tmp/test/.git".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||||
|
assert!(json.contains("trust_required"));
|
||||||
|
assert!(json.contains("/tmp/test"));
|
||||||
|
assert!(json.contains("test-repo"));
|
||||||
|
|
||||||
|
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
match deserialized {
|
||||||
|
TrustEvent::TrustRequired {
|
||||||
|
cwd,
|
||||||
|
repo,
|
||||||
|
worktree,
|
||||||
|
} => {
|
||||||
|
assert_eq!(cwd, "/tmp/test");
|
||||||
|
assert_eq!(repo, Some("test-repo".to_string()));
|
||||||
|
assert_eq!(worktree, Some("/tmp/test/.git".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("wrong event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_event_resolved_serialization() {
|
||||||
|
let event = TrustEvent::TrustResolved {
|
||||||
|
cwd: "/tmp/test".to_string(),
|
||||||
|
policy: TrustPolicy::AutoTrust,
|
||||||
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||||
|
assert!(json.contains("trust_resolved"));
|
||||||
|
assert!(json.contains("auto_allowlisted"));
|
||||||
|
|
||||||
|
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
match deserialized {
|
||||||
|
TrustEvent::TrustResolved { resolution, .. } => {
|
||||||
|
assert_eq!(resolution, TrustResolution::AutoAllowlisted);
|
||||||
|
}
|
||||||
|
_ => panic!("wrong event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root,
|
||||||
TrustPolicy, TrustResolver,
|
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution,
|
||||||
|
TrustResolver,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -197,7 +689,7 @@ mod tests {
|
|||||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
|
let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>");
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(decision, TrustDecision::NotRequired);
|
assert_eq!(decision, TrustDecision::NotRequired);
|
||||||
@@ -213,23 +705,23 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/worktrees/repo-a",
|
"/tmp/worktrees/repo-a",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
assert_eq!(
|
let events = decision.events();
|
||||||
decision.events(),
|
assert_eq!(events.len(), 2);
|
||||||
&[
|
assert!(matches!(events[0], TrustEvent::TrustRequired { .. }));
|
||||||
TrustEvent::TrustRequired {
|
assert!(matches!(
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
events[1],
|
||||||
},
|
TrustEvent::TrustResolved {
|
||||||
TrustEvent::TrustResolved {
|
policy: TrustPolicy::AutoTrust,
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
resolution: TrustResolution::AutoAllowlisted,
|
||||||
policy: TrustPolicy::AutoTrust,
|
..
|
||||||
},
|
}
|
||||||
]
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -240,6 +732,7 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/other/repo-b",
|
"/tmp/other/repo-b",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,6 +742,8 @@ mod tests {
|
|||||||
decision.events(),
|
decision.events(),
|
||||||
&[TrustEvent::TrustRequired {
|
&[TrustEvent::TrustRequired {
|
||||||
cwd: "/tmp/other/repo-b".to_string(),
|
cwd: "/tmp/other/repo-b".to_string(),
|
||||||
|
repo: Some("repo-b".to_string()),
|
||||||
|
worktree: None,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,6 +760,7 @@ mod tests {
|
|||||||
// when
|
// when
|
||||||
let decision = resolver.resolve(
|
let decision = resolver.resolve(
|
||||||
"/tmp/worktrees/repo-c",
|
"/tmp/worktrees/repo-c",
|
||||||
|
None,
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,6 +771,8 @@ mod tests {
|
|||||||
&[
|
&[
|
||||||
TrustEvent::TrustRequired {
|
TrustEvent::TrustRequired {
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||||
|
repo: Some("repo-c".to_string()),
|
||||||
|
worktree: None,
|
||||||
},
|
},
|
||||||
TrustEvent::TrustDenied {
|
TrustEvent::TrustDenied {
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||||
@@ -284,6 +782,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_trusts_with_glob_pattern_allowlist() {
|
||||||
|
// given
|
||||||
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*"));
|
||||||
|
|
||||||
|
// when - any repo under /tmp/worktrees should auto-trust
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/worktrees/repo-a",
|
||||||
|
None,
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_with_worktree_pattern_matching() {
|
||||||
|
// given
|
||||||
|
let config = TrustConfig::new().with_allowlisted_entry(
|
||||||
|
TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"),
|
||||||
|
);
|
||||||
|
let resolver = TrustResolver::new(config);
|
||||||
|
|
||||||
|
// when - with worktree that matches the pattern
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/worktrees/repo-a",
|
||||||
|
Some("/tmp/worktrees/repo-a/.git"),
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then - should auto-trust because both patterns match
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_approval_detected_from_screen_text() {
|
||||||
|
// given
|
||||||
|
let resolver = TrustResolver::new(TrustConfig::new());
|
||||||
|
|
||||||
|
// when - screen text indicates manual approval
|
||||||
|
let decision = resolver.resolve(
|
||||||
|
"/tmp/some/repo",
|
||||||
|
None,
|
||||||
|
"Do you trust the files in this folder?\nUser selected: Yes, I trust this folder",
|
||||||
|
);
|
||||||
|
|
||||||
|
// then - should detect manual approval
|
||||||
|
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
|
||||||
|
let events = decision.events();
|
||||||
|
assert!(events.len() >= 2);
|
||||||
|
assert!(matches!(
|
||||||
|
events[events.len() - 1],
|
||||||
|
TrustEvent::TrustResolved {
|
||||||
|
resolution: TrustResolution::ManualApproval,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sibling_prefix_does_not_match_trusted_root() {
|
fn sibling_prefix_does_not_match_trusted_root() {
|
||||||
// given
|
// given
|
||||||
@@ -296,4 +854,70 @@ mod tests {
|
|||||||
// then
|
// then
|
||||||
assert!(!matched);
|
assert!(!matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_manual_approval_cues() {
|
||||||
|
assert!(detect_manual_approval(
|
||||||
|
"User selected: Yes, I trust this folder"
|
||||||
|
));
|
||||||
|
assert!(detect_manual_approval(
|
||||||
|
"I trust this repository and its contents"
|
||||||
|
));
|
||||||
|
assert!(detect_manual_approval("Approval granted by user"));
|
||||||
|
assert!(!detect_manual_approval(
|
||||||
|
"Do you trust the files in this folder?"
|
||||||
|
));
|
||||||
|
assert!(!detect_manual_approval("Some unrelated text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_config_default_emit_events() {
|
||||||
|
let config = TrustConfig::default();
|
||||||
|
assert!(config.emit_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_resolver_trusts_method() {
|
||||||
|
let resolver = TrustResolver::new(
|
||||||
|
TrustConfig::new()
|
||||||
|
.with_allowlisted("/tmp/worktrees/*")
|
||||||
|
.with_denied("/tmp/worktrees/bad-repo"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should trust allowlisted paths
|
||||||
|
assert!(resolver.trusts("/tmp/worktrees/good-repo", None));
|
||||||
|
|
||||||
|
// Should not trust denied paths
|
||||||
|
assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None));
|
||||||
|
|
||||||
|
// Should not trust unknown paths
|
||||||
|
assert!(!resolver.trusts("/tmp/other/repo", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_policy_serde_roundtrip() {
|
||||||
|
for policy in [
|
||||||
|
TrustPolicy::AutoTrust,
|
||||||
|
TrustPolicy::RequireApproval,
|
||||||
|
TrustPolicy::Deny,
|
||||||
|
] {
|
||||||
|
let json = serde_json::to_string(&policy).expect("serialization failed");
|
||||||
|
let deserialized: TrustPolicy =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
assert_eq!(policy, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_resolution_serde_roundtrip() {
|
||||||
|
for resolution in [
|
||||||
|
TrustResolution::AutoAllowlisted,
|
||||||
|
TrustResolution::ManualApproval,
|
||||||
|
] {
|
||||||
|
let json = serde_json::to_string(&resolution).expect("serialization failed");
|
||||||
|
let deserialized: TrustResolution =
|
||||||
|
serde_json::from_str(&json).expect("deserialization failed");
|
||||||
|
assert_eq!(resolution, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ fn now_secs() -> u64 {
|
|||||||
pub enum WorkerStatus {
|
pub enum WorkerStatus {
|
||||||
Spawning,
|
Spawning,
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
ToolPermissionRequired,
|
||||||
ReadyForPrompt,
|
ReadyForPrompt,
|
||||||
Running,
|
Running,
|
||||||
Finished,
|
Finished,
|
||||||
@@ -41,6 +42,7 @@ impl std::fmt::Display for WorkerStatus {
|
|||||||
match self {
|
match self {
|
||||||
Self::Spawning => write!(f, "spawning"),
|
Self::Spawning => write!(f, "spawning"),
|
||||||
Self::TrustRequired => write!(f, "trust_required"),
|
Self::TrustRequired => write!(f, "trust_required"),
|
||||||
|
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||||
Self::Running => write!(f, "running"),
|
Self::Running => write!(f, "running"),
|
||||||
Self::Finished => write!(f, "finished"),
|
Self::Finished => write!(f, "finished"),
|
||||||
@@ -53,6 +55,7 @@ impl std::fmt::Display for WorkerStatus {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerFailureKind {
|
pub enum WorkerFailureKind {
|
||||||
TrustGate,
|
TrustGate,
|
||||||
|
ToolPermissionGate,
|
||||||
PromptDelivery,
|
PromptDelivery,
|
||||||
Protocol,
|
Protocol,
|
||||||
Provider,
|
Provider,
|
||||||
@@ -71,6 +74,7 @@ pub struct WorkerFailure {
|
|||||||
pub enum WorkerEventKind {
|
pub enum WorkerEventKind {
|
||||||
Spawning,
|
Spawning,
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
ToolPermissionRequired,
|
||||||
TrustResolved,
|
TrustResolved,
|
||||||
ReadyForPrompt,
|
ReadyForPrompt,
|
||||||
PromptMisdelivery,
|
PromptMisdelivery,
|
||||||
@@ -104,6 +108,8 @@ pub enum WorkerPromptTarget {
|
|||||||
pub enum StartupFailureClassification {
|
pub enum StartupFailureClassification {
|
||||||
/// Trust prompt is required but not detected/resolved
|
/// Trust prompt is required but not detected/resolved
|
||||||
TrustRequired,
|
TrustRequired,
|
||||||
|
/// Tool permission prompt is required before startup can continue
|
||||||
|
ToolPermissionRequired,
|
||||||
/// Prompt was delivered to wrong target (shell misdelivery)
|
/// Prompt was delivered to wrong target (shell misdelivery)
|
||||||
PromptMisdelivery,
|
PromptMisdelivery,
|
||||||
/// Prompt was sent but acceptance timed out
|
/// Prompt was sent but acceptance timed out
|
||||||
@@ -130,6 +136,14 @@ pub struct StartupEvidenceBundle {
|
|||||||
pub prompt_acceptance_state: bool,
|
pub prompt_acceptance_state: bool,
|
||||||
/// Result of trust prompt detection at timeout
|
/// Result of trust prompt detection at timeout
|
||||||
pub trust_prompt_detected: bool,
|
pub trust_prompt_detected: bool,
|
||||||
|
/// Result of tool permission prompt detection at timeout
|
||||||
|
pub tool_permission_prompt_detected: bool,
|
||||||
|
/// Age in seconds of the latest tool permission prompt, when observed
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_permission_prompt_age_seconds: Option<u64>,
|
||||||
|
/// Whether the prompt surface exposed only a session allow path or also an always-allow path
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
|
||||||
/// Transport health summary (true = healthy/responsive)
|
/// Transport health summary (true = healthy/responsive)
|
||||||
pub transport_healthy: bool,
|
pub transport_healthy: bool,
|
||||||
/// MCP health summary (true = all servers healthy)
|
/// MCP health summary (true = all servers healthy)
|
||||||
@@ -146,6 +160,15 @@ pub enum WorkerEventPayload {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
resolution: Option<WorkerTrustResolution>,
|
resolution: Option<WorkerTrustResolution>,
|
||||||
},
|
},
|
||||||
|
ToolPermissionPrompt {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
server_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_name: Option<String>,
|
||||||
|
prompt_age_seconds: u64,
|
||||||
|
allow_scope: ToolPermissionAllowScope,
|
||||||
|
prompt_preview: String,
|
||||||
|
},
|
||||||
PromptDelivery {
|
PromptDelivery {
|
||||||
prompt_preview: String,
|
prompt_preview: String,
|
||||||
observed_target: WorkerPromptTarget,
|
observed_target: WorkerPromptTarget,
|
||||||
@@ -163,6 +186,14 @@ pub enum WorkerEventPayload {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolPermissionAllowScope {
|
||||||
|
SessionOnly,
|
||||||
|
SessionOrAlways,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerTaskReceipt {
|
pub struct WorkerTaskReceipt {
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
@@ -276,6 +307,29 @@ impl WorkerRegistry {
|
|||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
||||||
let lowered = screen_text.to_ascii_lowercase();
|
let lowered = screen_text.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if let Some(tool_prompt) = detect_tool_permission_prompt(screen_text, &lowered) {
|
||||||
|
worker.status = WorkerStatus::ToolPermissionRequired;
|
||||||
|
worker.last_error = Some(WorkerFailure {
|
||||||
|
kind: WorkerFailureKind::ToolPermissionGate,
|
||||||
|
message: tool_prompt.message(),
|
||||||
|
created_at: now_secs(),
|
||||||
|
});
|
||||||
|
push_event(
|
||||||
|
worker,
|
||||||
|
WorkerEventKind::ToolPermissionRequired,
|
||||||
|
WorkerStatus::ToolPermissionRequired,
|
||||||
|
Some("tool permission prompt detected".to_string()),
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||||
|
server_name: tool_prompt.server_name,
|
||||||
|
tool_name: tool_prompt.tool_name,
|
||||||
|
prompt_age_seconds: 0,
|
||||||
|
allow_scope: tool_prompt.allow_scope,
|
||||||
|
prompt_preview: tool_prompt.prompt_preview,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Ok(worker.clone());
|
||||||
|
}
|
||||||
|
|
||||||
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
||||||
worker.status = WorkerStatus::TrustRequired;
|
worker.status = WorkerStatus::TrustRequired;
|
||||||
worker.last_error = Some(WorkerFailure {
|
worker.last_error = Some(WorkerFailure {
|
||||||
@@ -503,7 +557,9 @@ impl WorkerRegistry {
|
|||||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||||
blocked: matches!(
|
blocked: matches!(
|
||||||
worker.status,
|
worker.status,
|
||||||
WorkerStatus::TrustRequired | WorkerStatus::Failed
|
WorkerStatus::TrustRequired
|
||||||
|
| WorkerStatus::ToolPermissionRequired
|
||||||
|
| WorkerStatus::Failed
|
||||||
),
|
),
|
||||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
replay_prompt_ready: worker.replay_prompt.is_some(),
|
||||||
last_error: worker.last_error.clone(),
|
last_error: worker.last_error.clone(),
|
||||||
@@ -624,6 +680,18 @@ impl WorkerRegistry {
|
|||||||
|
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let elapsed = now.saturating_sub(worker.created_at);
|
let elapsed = now.saturating_sub(worker.created_at);
|
||||||
|
let latest_tool_permission_event = worker
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired);
|
||||||
|
let tool_permission_allow_scope =
|
||||||
|
latest_tool_permission_event.and_then(|event| match &event.payload {
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt { allow_scope, .. }) => {
|
||||||
|
Some(*allow_scope)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
// Build evidence bundle
|
// Build evidence bundle
|
||||||
let evidence = StartupEvidenceBundle {
|
let evidence = StartupEvidenceBundle {
|
||||||
@@ -640,6 +708,13 @@ impl WorkerRegistry {
|
|||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| e.kind == WorkerEventKind::TrustRequired),
|
.any(|e| e.kind == WorkerEventKind::TrustRequired),
|
||||||
|
tool_permission_prompt_detected: worker
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.kind == WorkerEventKind::ToolPermissionRequired),
|
||||||
|
tool_permission_prompt_age_seconds: latest_tool_permission_event
|
||||||
|
.map(|event| now.saturating_sub(event.timestamp)),
|
||||||
|
tool_permission_allow_scope,
|
||||||
transport_healthy,
|
transport_healthy,
|
||||||
mcp_healthy,
|
mcp_healthy,
|
||||||
elapsed_seconds: elapsed,
|
elapsed_seconds: elapsed,
|
||||||
@@ -694,6 +769,13 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
|
|||||||
return StartupFailureClassification::TrustRequired;
|
return StartupFailureClassification::TrustRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for tool permission prompts that were not resolved
|
||||||
|
if evidence.tool_permission_prompt_detected
|
||||||
|
&& evidence.last_lifecycle_state == WorkerStatus::ToolPermissionRequired
|
||||||
|
{
|
||||||
|
return StartupFailureClassification::ToolPermissionRequired;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for prompt acceptance timeout
|
// Check for prompt acceptance timeout
|
||||||
if evidence.prompt_sent_at.is_some()
|
if evidence.prompt_sent_at.is_some()
|
||||||
&& !evidence.prompt_acceptance_state
|
&& !evidence.prompt_acceptance_state
|
||||||
@@ -815,6 +897,140 @@ fn normalize_path(path: &str) -> PathBuf {
|
|||||||
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ToolPermissionPromptObservation {
|
||||||
|
server_name: Option<String>,
|
||||||
|
tool_name: Option<String>,
|
||||||
|
allow_scope: ToolPermissionAllowScope,
|
||||||
|
prompt_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolPermissionPromptObservation {
|
||||||
|
fn message(&self) -> String {
|
||||||
|
match (&self.server_name, &self.tool_name) {
|
||||||
|
(Some(server), Some(tool)) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {server}.{tool}")
|
||||||
|
}
|
||||||
|
(Some(server), None) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {server}")
|
||||||
|
}
|
||||||
|
(None, Some(tool)) => {
|
||||||
|
format!("worker boot blocked on tool permission prompt for {tool}")
|
||||||
|
}
|
||||||
|
(None, None) => "worker boot blocked on tool permission prompt".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_tool_permission_prompt(
|
||||||
|
screen_text: &str,
|
||||||
|
lowered: &str,
|
||||||
|
) -> Option<ToolPermissionPromptObservation> {
|
||||||
|
let looks_like_prompt = lowered.contains("allow the")
|
||||||
|
&& lowered.contains("server")
|
||||||
|
&& lowered.contains("tool")
|
||||||
|
&& lowered.contains("run");
|
||||||
|
let looks_like_tool_gate = lowered.contains("allow tool") && lowered.contains("run");
|
||||||
|
if !looks_like_prompt && !looks_like_tool_gate {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt_line = screen_text
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|line| {
|
||||||
|
let lowered_line = line.to_ascii_lowercase();
|
||||||
|
lowered_line.contains("allow")
|
||||||
|
&& lowered_line.contains("tool")
|
||||||
|
&& (lowered_line.contains("run") || lowered_line.contains("server"))
|
||||||
|
})
|
||||||
|
.unwrap_or(screen_text)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
let tool_name = extract_quoted_value(prompt_line)
|
||||||
|
.or_else(|| extract_after(prompt_line, "tool ").map(|token| normalize_tool_token(&token)));
|
||||||
|
let server_name = extract_between(prompt_line, "the ", " server")
|
||||||
|
.map(|server| server.trim_end_matches(" MCP").to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
tool_name
|
||||||
|
.as_deref()
|
||||||
|
.and_then(extract_server_from_qualified_tool)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(ToolPermissionPromptObservation {
|
||||||
|
server_name,
|
||||||
|
tool_name,
|
||||||
|
allow_scope: detect_tool_permission_allow_scope(lowered),
|
||||||
|
prompt_preview: prompt_preview(prompt_line),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_tool_permission_allow_scope(lowered: &str) -> ToolPermissionAllowScope {
|
||||||
|
let always_allow_capable = [
|
||||||
|
"always allow",
|
||||||
|
"allow always",
|
||||||
|
"allow this tool always",
|
||||||
|
"allow for all sessions",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| lowered.contains(needle));
|
||||||
|
|
||||||
|
if always_allow_capable {
|
||||||
|
return ToolPermissionAllowScope::SessionOrAlways;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_allow_capable = [
|
||||||
|
"allow once",
|
||||||
|
"allow for this session",
|
||||||
|
"allow this session",
|
||||||
|
"yes, allow",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| lowered.contains(needle));
|
||||||
|
|
||||||
|
if session_allow_capable {
|
||||||
|
ToolPermissionAllowScope::SessionOnly
|
||||||
|
} else {
|
||||||
|
ToolPermissionAllowScope::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_quoted_value(text: &str) -> Option<String> {
|
||||||
|
let start = text.find('"')? + 1;
|
||||||
|
let rest = &text[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_between(text: &str, prefix: &str, suffix: &str) -> Option<String> {
|
||||||
|
let start = text.find(prefix)? + prefix.len();
|
||||||
|
let rest = &text[start..];
|
||||||
|
let end = rest.find(suffix)?;
|
||||||
|
let value = rest[..end].trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_after(text: &str, prefix: &str) -> Option<String> {
|
||||||
|
let start = text.to_ascii_lowercase().find(prefix)? + prefix.len();
|
||||||
|
let value = text[start..]
|
||||||
|
.split_whitespace()
|
||||||
|
.next()?
|
||||||
|
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'');
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tool_token(token: &str) -> String {
|
||||||
|
token
|
||||||
|
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
|
||||||
|
let rest = tool.strip_prefix("mcp__")?;
|
||||||
|
let (server, _) = rest.split_once("__")?;
|
||||||
|
(!server.is_empty()).then(|| server.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||||
[
|
[
|
||||||
"do you trust the files in this folder",
|
"do you trust the files in this folder",
|
||||||
@@ -1134,6 +1350,96 @@ mod tests {
|
|||||||
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_permission_prompt_blocks_worker_with_structured_event() {
|
||||||
|
let registry = WorkerRegistry::new();
|
||||||
|
let worker = registry.create("/tmp/repo-mcp", &[], true);
|
||||||
|
|
||||||
|
let blocked = registry
|
||||||
|
.observe(
|
||||||
|
&worker.worker_id,
|
||||||
|
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?\n\
|
||||||
|
1. Yes, allow once\n\
|
||||||
|
2. Always allow this tool",
|
||||||
|
)
|
||||||
|
.expect("tool permission observe should succeed");
|
||||||
|
|
||||||
|
assert_eq!(blocked.status, WorkerStatus::ToolPermissionRequired);
|
||||||
|
assert_eq!(
|
||||||
|
blocked
|
||||||
|
.last_error
|
||||||
|
.as_ref()
|
||||||
|
.expect("tool permission error should exist")
|
||||||
|
.kind,
|
||||||
|
WorkerFailureKind::ToolPermissionGate
|
||||||
|
);
|
||||||
|
let event = blocked
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired)
|
||||||
|
.expect("tool permission event should exist");
|
||||||
|
assert_eq!(
|
||||||
|
event.payload,
|
||||||
|
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||||
|
server_name: Some("omx_memory".to_string()),
|
||||||
|
tool_name: Some("project_memory_read".to_string()),
|
||||||
|
prompt_age_seconds: 0,
|
||||||
|
allow_scope: ToolPermissionAllowScope::SessionOrAlways,
|
||||||
|
prompt_preview: prompt_preview(
|
||||||
|
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let readiness = registry
|
||||||
|
.await_ready(&worker.worker_id)
|
||||||
|
.expect("ready snapshot should load");
|
||||||
|
assert!(readiness.blocked);
|
||||||
|
assert!(!readiness.ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_timeout_classifies_tool_permission_prompt() {
|
||||||
|
let registry = WorkerRegistry::new();
|
||||||
|
let worker = registry.create("/tmp/repo-mcp-timeout", &[], true);
|
||||||
|
|
||||||
|
registry
|
||||||
|
.observe(
|
||||||
|
&worker.worker_id,
|
||||||
|
"Allow the omx_memory MCP server to run tool \"notepad_read\"?\n\
|
||||||
|
1. Yes, allow once",
|
||||||
|
)
|
||||||
|
.expect("tool permission observe should succeed");
|
||||||
|
|
||||||
|
let timed_out = registry
|
||||||
|
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
|
||||||
|
.expect("startup timeout observe should succeed");
|
||||||
|
let event = timed_out
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == WorkerEventKind::StartupNoEvidence)
|
||||||
|
.expect("startup no evidence event should exist");
|
||||||
|
|
||||||
|
match event.payload.as_ref() {
|
||||||
|
Some(WorkerEventPayload::StartupNoEvidence {
|
||||||
|
classification,
|
||||||
|
evidence,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(
|
||||||
|
*classification,
|
||||||
|
StartupFailureClassification::ToolPermissionRequired
|
||||||
|
);
|
||||||
|
assert!(evidence.tool_permission_prompt_detected);
|
||||||
|
assert_eq!(
|
||||||
|
evidence.tool_permission_allow_scope,
|
||||||
|
Some(ToolPermissionAllowScope::SessionOnly)
|
||||||
|
);
|
||||||
|
assert!(evidence.tool_permission_prompt_age_seconds.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("expected StartupNoEvidence payload"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
||||||
let registry = WorkerRegistry::new();
|
let registry = WorkerRegistry::new();
|
||||||
@@ -1634,6 +1940,9 @@ mod tests {
|
|||||||
prompt_sent_at: Some(1_234_567_890),
|
prompt_sent_at: Some(1_234_567_890),
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: true,
|
trust_prompt_detected: true,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: true,
|
transport_healthy: true,
|
||||||
mcp_healthy: false,
|
mcp_healthy: false,
|
||||||
elapsed_seconds: 60,
|
elapsed_seconds: 60,
|
||||||
@@ -1661,6 +1970,9 @@ mod tests {
|
|||||||
prompt_sent_at: None,
|
prompt_sent_at: None,
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: false,
|
trust_prompt_detected: false,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: false,
|
transport_healthy: false,
|
||||||
mcp_healthy: true,
|
mcp_healthy: true,
|
||||||
elapsed_seconds: 30,
|
elapsed_seconds: 30,
|
||||||
@@ -1678,6 +1990,9 @@ mod tests {
|
|||||||
prompt_sent_at: None,
|
prompt_sent_at: None,
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: false,
|
trust_prompt_detected: false,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: true,
|
transport_healthy: true,
|
||||||
mcp_healthy: true,
|
mcp_healthy: true,
|
||||||
elapsed_seconds: 10,
|
elapsed_seconds: 10,
|
||||||
@@ -1697,6 +2012,9 @@ mod tests {
|
|||||||
prompt_sent_at: None, // No prompt sent yet
|
prompt_sent_at: None, // No prompt sent yet
|
||||||
prompt_acceptance_state: false,
|
prompt_acceptance_state: false,
|
||||||
trust_prompt_detected: false,
|
trust_prompt_detected: false,
|
||||||
|
tool_permission_prompt_detected: false,
|
||||||
|
tool_permission_prompt_age_seconds: None,
|
||||||
|
tool_permission_allow_scope: None,
|
||||||
transport_healthy: true,
|
transport_healthy: true,
|
||||||
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
|
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
|
||||||
elapsed_seconds: 45,
|
elapsed_seconds: 45,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -126,6 +126,66 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
|
|||||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||||
|
// given a workspace pointed at the mock Anthropic service running the
|
||||||
|
// streaming_text scenario which only emits a single assistant text block
|
||||||
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
|
let server = runtime
|
||||||
|
.block_on(MockAnthropicService::spawn())
|
||||||
|
.expect("mock service should start");
|
||||||
|
let base_url = server.base_url();
|
||||||
|
|
||||||
|
let workspace = unique_temp_dir("text-prompt-mode");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
// when we invoke claw in normal text prompt mode for the streaming text scenario
|
||||||
|
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||||
|
let output = run_claw(
|
||||||
|
&workspace,
|
||||||
|
&config_home,
|
||||||
|
&home,
|
||||||
|
&base_url,
|
||||||
|
&[
|
||||||
|
"--model",
|
||||||
|
"sonnet",
|
||||||
|
"--permission-mode",
|
||||||
|
"read-only",
|
||||||
|
&prompt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// then stdout should contain the final assistant text, not just spinner output
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
|
let plain_stdout = strip_ansi_codes(&stdout);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("Mock streaming says hello from the parity harness."),
|
||||||
|
"text prompt stdout should include the assistant text ({stdout:?})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("✔ ✨ Done"),
|
||||||
|
"text prompt stdout should still include spinner completion ({stdout:?})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain_stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line == "Mock streaming says hello from the parity harness."),
|
||||||
|
"text prompt stdout should print the assistant text as its own line ({stdout:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_flag_with_json_output_emits_structured_json() {
|
fn compact_flag_with_json_output_emits_structured_json() {
|
||||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
@@ -215,3 +275,21 @@ fn unique_temp_dir(label: &str) -> PathBuf {
|
|||||||
std::process::id()
|
std::process::id()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strip_ansi_codes(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||||
|
chars.next();
|
||||||
|
while let Some(next) = chars.next() {
|
||||||
|
if ('@'..='~').contains(&next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|||||||
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Output, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_slash_command_in_repl_does_not_start_nested_tokio_runtime() {
|
||||||
|
// given
|
||||||
|
let workspace = unique_temp_dir("compact-repl-panic");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let output = run_claw_repl(&workspace, &config_home, &home, "/compact\n/exit\n");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"compact repl run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("Cannot start a runtime"),
|
||||||
|
"stderr must not contain nested runtime panic: {stderr:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("panicked at"),
|
||||||
|
"stderr must not contain panic output: {stderr:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
|
let plain_stdout = strip_ansi_codes(&stdout);
|
||||||
|
assert!(
|
||||||
|
plain_stdout.contains("Compaction skipped")
|
||||||
|
|| plain_stdout.contains("Result skipped")
|
||||||
|
|| plain_stdout.contains("Result compacted"),
|
||||||
|
"stdout should contain compact report output ({stdout:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_claw_repl(
|
||||||
|
cwd: &std::path::Path,
|
||||||
|
config_home: &std::path::Path,
|
||||||
|
home: &std::path::Path,
|
||||||
|
stdin: &str,
|
||||||
|
) -> Output {
|
||||||
|
let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw"));
|
||||||
|
let mut child = command
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env_clear()
|
||||||
|
.env("ANTHROPIC_API_KEY", "test-compact-repl-key")
|
||||||
|
.env("CLAW_CONFIG_HOME", config_home)
|
||||||
|
.env("HOME", home)
|
||||||
|
.env("NO_COLOR", "1")
|
||||||
|
.env("PATH", "/usr/bin:/bin")
|
||||||
|
.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")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn python_pty_command(claw: &str) -> Command {
|
||||||
|
let mut command = Command::new("python3");
|
||||||
|
command.args([
|
||||||
|
"-c",
|
||||||
|
r#"
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
claw = sys.argv[1]
|
||||||
|
payload = sys.stdin.buffer.read()
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
os.close(slave)
|
||||||
|
os.write(master, payload)
|
||||||
|
stdout, stderr = child.communicate(timeout=30)
|
||||||
|
os.close(master)
|
||||||
|
sys.stdout.buffer.write(stdout)
|
||||||
|
sys.stderr.buffer.write(stderr)
|
||||||
|
raise SystemExit(child.returncode)
|
||||||
|
"#,
|
||||||
|
claw,
|
||||||
|
]);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_ansi_codes(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||||
|
chars.next();
|
||||||
|
for next in chars.by_ref() {
|
||||||
|
if ('@'..='~').contains(&next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
@@ -22,6 +22,42 @@ fn help_emits_json_when_requested() {
|
|||||||
.contains("Usage:"));
|
.contains("Usage:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_help_emits_bounded_json_when_requested_384() {
|
||||||
|
let root = unique_temp_dir("export-help-json");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
|
||||||
|
assert_eq!(parsed["kind"], "help");
|
||||||
|
assert_eq!(parsed["topic"], "export");
|
||||||
|
assert_eq!(parsed["command"], "export");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["usage"],
|
||||||
|
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed["defaults"]["session"], "latest");
|
||||||
|
assert!(parsed["options"].as_array().expect("options").len() >= 4);
|
||||||
|
assert!(parsed.get("message").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_help_preserves_plaintext_in_text_mode_384() {
|
||||||
|
let root = unique_temp_dir("export-help-text");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
let output = run_claw(&root, &["export", "--help"], &[]);
|
||||||
|
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 utf8");
|
||||||
|
assert!(stdout.starts_with("Export\n"));
|
||||||
|
assert!(stdout.contains("Usage claw export"));
|
||||||
|
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_emits_json_when_requested() {
|
fn version_emits_json_when_requested() {
|
||||||
let root = unique_temp_dir("version-json");
|
let root = unique_temp_dir("version-json");
|
||||||
@@ -30,6 +66,15 @@ fn version_emits_json_when_requested() {
|
|||||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||||
assert_eq!(parsed["kind"], "version");
|
assert_eq!(parsed["kind"], "version");
|
||||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||||
|
// Provenance fields must be present for binary identification (#507).
|
||||||
|
assert!(
|
||||||
|
parsed["build_date"].is_string(),
|
||||||
|
"build_date must be a string in version JSON"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed["executable_path"].is_string(),
|
||||||
|
"executable_path must be a string in version JSON so callers can identify which binary is running"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -105,6 +150,18 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||||
assert_eq!(skills["kind"], "skills");
|
assert_eq!(skills["kind"], "skills");
|
||||||
assert_eq!(skills["action"], "list");
|
assert_eq!(skills["action"], "list");
|
||||||
|
|
||||||
|
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
|
||||||
|
assert_eq!(plugins["kind"], "plugin");
|
||||||
|
assert_eq!(plugins["action"], "list");
|
||||||
|
assert!(
|
||||||
|
plugins["reload_runtime"].is_boolean(),
|
||||||
|
"plugins reload_runtime should be a boolean"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["target"].is_null(),
|
||||||
|
"plugins target should be null when no plugin is targeted"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -348,6 +405,62 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(skills["action"], "list");
|
assert_eq!(skills["action"], "list");
|
||||||
assert!(skills["summary"]["total"].is_number());
|
assert!(skills["summary"]["total"].is_number());
|
||||||
assert!(skills["skills"].is_array());
|
assert!(skills["skills"].is_array());
|
||||||
|
|
||||||
|
let agents = assert_json_command_with_env(
|
||||||
|
&root,
|
||||||
|
&[
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--resume",
|
||||||
|
session_path.to_str().expect("utf8 session path"),
|
||||||
|
"/agents",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(agents["kind"], "agents");
|
||||||
|
assert_eq!(agents["action"], "list");
|
||||||
|
assert!(
|
||||||
|
agents["agents"].is_array(),
|
||||||
|
"agents field must be a JSON array"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
agents["count"].is_number(),
|
||||||
|
"count must be a number, not a text render"
|
||||||
|
);
|
||||||
|
|
||||||
|
let plugins = assert_json_command_with_env(
|
||||||
|
&root,
|
||||||
|
&[
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--resume",
|
||||||
|
session_path.to_str().expect("utf8 session path"),
|
||||||
|
"/plugins",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(plugins["kind"], "plugin");
|
||||||
|
assert_eq!(plugins["action"], "list");
|
||||||
|
assert!(
|
||||||
|
plugins["reload_runtime"].is_boolean(),
|
||||||
|
"plugins reload_runtime should be a boolean"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["target"].is_null(),
|
||||||
|
"plugins target should be null when no plugin is targeted"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -384,6 +497,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
|||||||
assert!(root.join("CLAUDE.md").exists());
|
assert!(root.join("CLAUDE.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_section_json_emits_section_and_value() {
|
||||||
|
let root = unique_temp_dir("config-section-json");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
// Without a section: should return base envelope (no section field).
|
||||||
|
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
|
||||||
|
assert_eq!(base["kind"], "config");
|
||||||
|
assert!(base["loaded_files"].is_number());
|
||||||
|
assert!(base["merged_keys"].is_number());
|
||||||
|
assert!(
|
||||||
|
base.get("section").is_none(),
|
||||||
|
"no section field without section arg"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With a known section: should add section + section_value fields.
|
||||||
|
for section in &["model", "env", "hooks", "plugins"] {
|
||||||
|
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
|
||||||
|
assert_eq!(result["kind"], "config", "section={section}");
|
||||||
|
assert_eq!(
|
||||||
|
result["section"].as_str(),
|
||||||
|
Some(*section),
|
||||||
|
"section field must match requested section, got {result:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.get("section_value").is_some(),
|
||||||
|
"section_value field must be present for section={section}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With an unsupported section: should return ok:false + error field.
|
||||||
|
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
|
||||||
|
assert_eq!(bad["kind"], "config");
|
||||||
|
assert_eq!(bad["ok"], false);
|
||||||
|
assert!(bad["error"].as_str().is_some());
|
||||||
|
assert!(bad["section"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||||
assert_json_command_with_env(current_dir, args, &[])
|
assert_json_command_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
// given
|
// given
|
||||||
let temp_dir = unique_temp_dir("resume-status-json");
|
let temp_dir = unique_temp_dir("resume-status-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let config_home = temp_dir.join("config-home");
|
||||||
|
fs::create_dir_all(&config_home).expect("isolated config home should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
let mut session = workspace_session(&temp_dir);
|
let mut session = workspace_session(&temp_dir);
|
||||||
@@ -238,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let output = run_claw(
|
// Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded,
|
||||||
|
// which would cause loaded_config_files to be non-zero (#65).
|
||||||
|
let output = run_claw_with_env(
|
||||||
&temp_dir,
|
&temp_dir,
|
||||||
&[
|
&[
|
||||||
"--output-format",
|
"--output-format",
|
||||||
@@ -247,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
session_path.to_str().expect("utf8 path"),
|
session_path.to_str().expect("utf8 path"),
|
||||||
"/status",
|
"/status",
|
||||||
],
|
],
|
||||||
|
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ use std::process::Command;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
max_tokens_for_model, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock,
|
max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError,
|
||||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||||
|
ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
use plugins::PluginTool;
|
use plugins::PluginTool;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
@@ -240,6 +241,13 @@ impl GlobalToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allowed.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"--allowedTools was provided with no usable tool names (got `{}`). Omit the flag to allow all tools.",
|
||||||
|
values.join(" ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(allowed))
|
Ok(Some(allowed))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3068,27 +3076,33 @@ fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
|
fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
|
||||||
if url.starts_with("http://") || url.starts_with("https://") {
|
let decoded = html_entity_decode_url(url);
|
||||||
return Some(html_entity_decode_url(url));
|
let parsed = if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
||||||
}
|
reqwest::Url::parse(&decoded).ok()
|
||||||
|
} else if decoded.starts_with("//") {
|
||||||
let joined = if url.starts_with("//") {
|
reqwest::Url::parse(&format!("https:{decoded}")).ok()
|
||||||
format!("https:{url}")
|
} else if decoded.starts_with('/') {
|
||||||
} else if url.starts_with('/') {
|
reqwest::Url::parse(&format!("https://duckduckgo.com{decoded}")).ok()
|
||||||
format!("https://duckduckgo.com{url}")
|
|
||||||
} else {
|
} else {
|
||||||
return None;
|
return None;
|
||||||
};
|
}?;
|
||||||
|
|
||||||
let parsed = reqwest::Url::parse(&joined).ok()?;
|
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||||
if parsed.path() == "/l/" || parsed.path() == "/l" {
|
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
|
||||||
|
&& (parsed.path() == "/l/" || parsed.path() == "/l")
|
||||||
|
{
|
||||||
for (key, value) in parsed.query_pairs() {
|
for (key, value) in parsed.query_pairs() {
|
||||||
if key == "uddg" {
|
if key == "uddg" {
|
||||||
return Some(html_entity_decode_url(value.as_ref()));
|
return Some(html_entity_decode_url(value.as_ref()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(joined)
|
|
||||||
|
if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
||||||
|
Some(decoded)
|
||||||
|
} else {
|
||||||
|
Some(parsed.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_entity_decode_url(url: &str) -> String {
|
fn html_entity_decode_url(url: &str) -> String {
|
||||||
@@ -3503,7 +3517,7 @@ where
|
|||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
||||||
let created_at = iso8601_now();
|
let created_at = iso8601_now();
|
||||||
let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
|
let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?;
|
||||||
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
|
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
|
||||||
|
|
||||||
let output_contents = format!(
|
let output_contents = format!(
|
||||||
@@ -3616,13 +3630,14 @@ fn build_agent_runtime(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
|
fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result<Vec<String>, String> {
|
||||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
let mut prompt = load_system_prompt(
|
let mut prompt = load_system_prompt(
|
||||||
cwd,
|
cwd,
|
||||||
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
|
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
|
||||||
std::env::consts::OS,
|
std::env::consts::OS,
|
||||||
"unknown",
|
"unknown",
|
||||||
|
model_family_identity_for(model),
|
||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
prompt.push(format!(
|
prompt.push(format!(
|
||||||
@@ -4752,6 +4767,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|block| match block {
|
.map(|block| match block {
|
||||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||||
|
ContentBlock::Thinking { .. } => InputContentBlock::Text {
|
||||||
|
text: String::new(),
|
||||||
|
},
|
||||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -4771,6 +4789,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|||||||
is_error: *is_error,
|
is_error: *is_error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
.filter(
|
||||||
|
|block| !matches!(block, InputContentBlock::Text { text } if text.is_empty()),
|
||||||
|
)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
(!content.is_empty()).then(|| InputMessage {
|
(!content.is_empty()).then(|| InputMessage {
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
@@ -6127,12 +6148,13 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
|
agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt,
|
||||||
derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome,
|
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
|
||||||
final_assistant_text, global_cron_registry, maybe_commit_provenance, mvp_tool_specs,
|
extract_recovery_outcome, final_assistant_text, global_cron_registry,
|
||||||
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
|
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
|
||||||
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
|
||||||
ProviderRuntimeClient, SubagentToolExecutor,
|
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
|
||||||
|
SubagentToolExecutor,
|
||||||
};
|
};
|
||||||
use api::OutputContentBlock;
|
use api::OutputContentBlock;
|
||||||
use runtime::ProviderFallbackConfig;
|
use runtime::ProviderFallbackConfig;
|
||||||
@@ -6883,6 +6905,21 @@ mod tests {
|
|||||||
assert!(empty_permission.contains("unsupported plugin permission: "));
|
assert!(empty_permission.contains("unsupported plugin permission: "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowed_tools_rejects_empty_token_lists() {
|
||||||
|
let registry = GlobalToolRegistry::builtin();
|
||||||
|
|
||||||
|
for raw in ["", ",,", " "] {
|
||||||
|
let err = registry
|
||||||
|
.normalize_allowed_tools(&[raw.to_string()])
|
||||||
|
.expect_err("empty allow-list input should be rejected");
|
||||||
|
assert!(
|
||||||
|
err.contains("--allowedTools was provided with no usable tool names"),
|
||||||
|
"unexpected error for {raw:?}: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||||
let registry = GlobalToolRegistry::builtin()
|
let registry = GlobalToolRegistry::builtin()
|
||||||
@@ -7126,6 +7163,98 @@ mod tests {
|
|||||||
assert!(error.contains("relative URL without a base") || error.contains("empty host"));
|
assert!(error.contains("relative URL without a base") || error.contains("empty host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_decodes_absolute_duckduckgo_redirect_urls() {
|
||||||
|
// given
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
assert!(request_line.contains("GET /search?q=duckduckgo+redirects "));
|
||||||
|
HttpResponse::html(
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
r#"
|
||||||
|
<html><body>
|
||||||
|
<a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Freqwest&rut=abc">Reqwest docs</a>
|
||||||
|
</body></html>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// when
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||||
|
format!("http://{}/search", server.addr()),
|
||||||
|
);
|
||||||
|
let result = execute_tool(
|
||||||
|
"WebSearch",
|
||||||
|
&json!({
|
||||||
|
"query": "duckduckgo redirects"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WebSearch should succeed");
|
||||||
|
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||||
|
|
||||||
|
// then
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||||
|
let results = output["results"].as_array().expect("results array");
|
||||||
|
let search_result = results
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.get("content").is_some())
|
||||||
|
.expect("search result block present");
|
||||||
|
let content = search_result["content"].as_array().expect("content array");
|
||||||
|
assert_eq!(content.len(), 1);
|
||||||
|
assert_eq!(content[0]["title"], "Reqwest docs");
|
||||||
|
assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_decodes_protocol_relative_duckduckgo_redirect_urls() {
|
||||||
|
// given
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
assert!(request_line.contains("GET /search?q=duckduckgo+protocol+relative "));
|
||||||
|
HttpResponse::html(
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
r#"
|
||||||
|
<html><body>
|
||||||
|
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Ftokio&rut=xyz">Tokio Docs</a>
|
||||||
|
</body></html>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// when
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||||
|
format!("http://{}/search", server.addr()),
|
||||||
|
);
|
||||||
|
let result = execute_tool(
|
||||||
|
"WebSearch",
|
||||||
|
&json!({
|
||||||
|
"query": "duckduckgo protocol relative"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("WebSearch should succeed");
|
||||||
|
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||||
|
|
||||||
|
// then
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||||
|
let results = output["results"].as_array().expect("results array");
|
||||||
|
let search_result = results
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.get("content").is_some())
|
||||||
|
.expect("search result block present");
|
||||||
|
let content = search_result["content"].as_array().expect("content array");
|
||||||
|
assert_eq!(content.len(), 1);
|
||||||
|
assert_eq!(content[0]["title"], "Tokio Docs");
|
||||||
|
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
|
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
@@ -8387,6 +8516,28 @@ mod tests {
|
|||||||
assert!(!verification.contains("write_file"));
|
assert!(!verification.contains("write_file"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subagent_system_prompt_uses_resolved_model_identity() {
|
||||||
|
// given: a temporary workspace and an OpenAI-compatible subagent model
|
||||||
|
let _guard = env_guard();
|
||||||
|
let root = temp_path("subagent-prompt-identity");
|
||||||
|
fs::create_dir_all(&root).expect("create temp workspace");
|
||||||
|
let previous = std::env::current_dir().expect("current dir");
|
||||||
|
std::env::set_current_dir(&root).expect("enter temp workspace");
|
||||||
|
|
||||||
|
// when: building the subagent system prompt
|
||||||
|
let prompt = build_agent_system_prompt("Explore", "openai/gpt-4.1-mini")
|
||||||
|
.expect("subagent system prompt should build")
|
||||||
|
.join("\n");
|
||||||
|
std::env::set_current_dir(previous).expect("restore current dir");
|
||||||
|
|
||||||
|
// then: the prompt renders a generic model family identity
|
||||||
|
assert!(prompt.contains("Model family: an AI assistant"));
|
||||||
|
assert!(!prompt.contains("Model family: Claude Opus 4.6"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp workspace");
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct MockSubagentApiClient {
|
struct MockSubagentApiClient {
|
||||||
calls: usize,
|
calls: usize,
|
||||||
|
|||||||
68
scripts/dogfood-build.sh
Executable file
68
scripts/dogfood-build.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# dogfood-build.sh — Build claw from current checkout and verify provenance.
|
||||||
|
#
|
||||||
|
# Injects GIT_SHA at build time so version JSON is non-null.
|
||||||
|
# Suppresses Cargo compile noise on stderr.
|
||||||
|
# Prints the verified binary path on success. Use as:
|
||||||
|
#
|
||||||
|
# CLAW=$(bash scripts/dogfood-build.sh)
|
||||||
|
#
|
||||||
|
# Then dogfood with config isolation (avoids real user config bleeding in):
|
||||||
|
#
|
||||||
|
# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
RUST_DIR="$REPO_ROOT/rust"
|
||||||
|
BINARY="$RUST_DIR/target/debug/claw"
|
||||||
|
EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
echo "▶ Building claw from $REPO_ROOT" >&2
|
||||||
|
echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2
|
||||||
|
|
||||||
|
# Inject GIT_SHA so version JSON returns a non-null sha.
|
||||||
|
# Redirect cargo stderr to /dev/null to suppress compile noise;
|
||||||
|
# on build failure cargo exits non-zero and set -e aborts.
|
||||||
|
if ! GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||||
|
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||||
|
-p rusty-claude-cli -q 2>/dev/null; then
|
||||||
|
# Re-run with visible output so the user sees the error
|
||||||
|
echo "✗ Build failed — rerunning with output:" >&2
|
||||||
|
GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||||
|
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||||
|
-p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$BINARY" ]]; then
|
||||||
|
echo "✗ Binary not found at $BINARY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \
|
||||||
|
|| echo "null")
|
||||||
|
|
||||||
|
if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then
|
||||||
|
echo "✗ Provenance check failed: binary reports git_sha: null" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then
|
||||||
|
echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " export CLAW=$BINARY" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Dogfood with isolated config (no real user config on stderr):" >&2
|
||||||
|
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
|
||||||
|
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
|
||||||
|
echo " rm -rf \$CLAW_ISOLATED" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
|
||||||
|
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
|
||||||
|
echo "$BINARY"
|
||||||
7
scripts/fmt.sh
Executable file
7
scripts/fmt.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT/rust"
|
||||||
|
exec cargo fmt "$@"
|
||||||
Reference in New Issue
Block a user