Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
1d635cbca5 docs: update README — ultraworkers, clawhip, creator credits, remove instructkr/WSJ 2026-04-04 15:33:53 +00:00
29 changed files with 254 additions and 1189 deletions

View File

@@ -1,68 +0,0 @@
name: Release binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build-${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
os: ubuntu-latest
bin: claw
artifact_name: claw-linux-x64
- name: macos-arm64
os: macos-14
bin: claw
artifact_name: claw-macos-arm64
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Build release binary
run: cargo build --release -p rusty-claude-cli
- name: Package artifact
shell: bash
run: |
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: rust/dist/${{ matrix.artifact_name }}
- name: Upload release asset
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: rust/dist/${{ matrix.artifact_name }}
fail_on_unmatched_files: true

View File

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

View File

@@ -1,4 +1,4 @@
# Rewriting Project Claw Code # Project Claw Code
<p align="center"> <p align="center">
<strong>⭐ The fastest repo in history to surpass 50K stars, reaching the milestone in just 2 hours after publication ⭐</strong> <strong>⭐ The fastest repo in history to surpass 50K stars, reaching the milestone in just 2 hours after publication ⭐</strong>
@@ -15,64 +15,43 @@
</p> </p>
<p align="center"> <p align="center">
<img src="assets/clawd-hero.jpeg" alt="Claw" width="300" /> <img src="assets/sigrid-photo.png" alt="Claw Code" width="500" />
</p> </p>
<p align="center"> <p align="center">
<strong>Autonomously maintained by lobsters/claws — not by human hands</strong> <strong>A community-built coding harness built on open agent frameworks</strong>
</p> </p>
<p align="center"> ---
<a href="https://github.com/Yeachan-Heo/clawhip">clawhip</a> ·
<a href="https://github.com/code-yeongyu/oh-my-openagent">oh-my-openagent</a> · ## Built With
<a href="https://github.com/Yeachan-Heo/oh-my-claudecode">oh-my-claudecode</a> ·
<a href="https://github.com/Yeachan-Heo/oh-my-codex">oh-my-codex</a> · This project is built and maintained using a combination of open agent frameworks:
<a href="https://discord.gg/6ztZB9jvWq">UltraWorkers Discord</a>
</p> - [**clawhip**](https://github.com/Yeachan-Heo/clawhip) — event-to-channel notification router and orchestration layer
- [**oh-my-openagent (OmO)**](https://github.com/code-yeongyu/oh-my-openagent) — open-source agent framework
- [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — Codex CLI extensions and workflow tools
- [**oh-my-claudecode (OmC)**](https://github.com/Yeachan-Heo/oh-my-claudecode) — Claude Code workflow extensions
> [!IMPORTANT] > [!IMPORTANT]
> The active Rust workspace now lives in [`rust/`](./rust). Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows, then use [`rust/README.md`](./rust/README.md) for crate-level details. > The active Rust workspace now lives in [`rust/`](./rust). Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows, then use [`rust/README.md`](./rust/README.md) for crate-level details.
> Want the bigger idea behind this repo? Read [`PHILOSOPHY.md`](./PHILOSOPHY.md) and Sigrid Jin's public explanation: https://x.com/realsigridjin/status/2039472968624185713
> Shout-out to the UltraWorkers ecosystem powering this repo: [clawhip](https://github.com/Yeachan-Heo/clawhip), [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent), [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode), [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex), and the [UltraWorkers Discord](https://discord.gg/6ztZB9jvWq).
--- ---
## Backstory ## Backstory
This repo is maintained by **lobsters/claws**, not by a conventional human-only dev team. This project began as a community response to the Claude Code exposure, and has since grown into a serious engineering effort to build the most capable open coding harness possible.
The people behind the system are [Bellman / Yeachan Heo](https://github.com/Yeachan-Heo) and friends like [Yeongyu](https://github.com/code-yeongyu), but the repo itself is being pushed forward by autonomous claw workflows: parallel coding sessions, event-driven orchestration, recovery loops, and machine-readable lane state. The entire development is orchestrated using the open agent frameworks listed above, with parallel code review, persistent execution loops, and architect-level verification driven through agent workflows.
In practice, that means this project is not just *about* coding agents — it is being **actively built by them**. Features, tests, telemetry, docs, and workflow hardening are landed through claw-driven loops using [clawhip](https://github.com/Yeachan-Heo/clawhip), [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent), [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode), and [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex). The project is actively maintained by a distributed team using open tooling — no proprietary infrastructure required.
This repository exists to prove that an open coding harness can be built **autonomously, in public, and at high velocity** — with humans setting direction and claws doing the grinding. See the full origin story and recent updates here:
See the public build story here:
https://x.com/realsigridjin/status/2039472968624185713 https://x.com/realsigridjin/status/2039472968624185713
![Tweet screenshot](assets/tweet-screenshot.png)
--- ---
## Porting Status
The main source tree is now Python-first.
- `src/` contains the active Python porting workspace
- `tests/` verifies the current Python workspace
- the exposed snapshot is no longer part of the tracked repository state
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
## Why this rewrite exists
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
This repository now focuses on Python porting work instead.
## Repository Layout ## Repository Layout
```text ```text
@@ -147,33 +126,13 @@ python3 -m src.main tools --limit 10
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source. The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
## Built with `oh-my-codex`
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
- **`$team` mode:** used for coordinated parallel review and architectural feedback
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
### OmX workflow screenshots
![OmX workflow screenshot 1](assets/omx/omx-readme-review-1.png)
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
![OmX workflow screenshot 2](assets/omx/omx-readme-review-2.png)
*Split-pane review and verification flow during the final README wording pass.*
## Community ## Community
<p align="center"> <p align="center">
<a href="https://discord.gg/6ztZB9jvWq"><img src="https://img.shields.io/badge/UltraWorkers-Discord-5865F2?logo=discord&style=for-the-badge" alt="UltraWorkers Discord" /></a> <a href="https://discord.gg/6ztZB9jvWq"><img src="https://img.shields.io/badge/Join%20Discord-6ztZB9jvWq-5865F2?logo=discord&style=for-the-badge" alt="Join Discord" /></a>
</p> </p>
Join the [**UltraWorkers Discord**](https://discord.gg/6ztZB9jvWq) — the community around clawhip, oh-my-openagent, oh-my-claudecode, oh-my-codex, and claw-code. Come chat about LLMs, harness engineering, agent workflows, and autonomous software development. Join the [**claw-code Discord**](https://discord.gg/6ztZB9jvWq) — come chat about LLMs, harness engineering, agent workflows, and everything in between.
[![Discord](https://img.shields.io/badge/Join%20Discord-UltraWorkers-5865F2?logo=discord&style=for-the-badge)](https://discord.gg/6ztZB9jvWq)
## Star History ## Star History

View File

@@ -272,18 +272,6 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
**P0 — Fix first (CI reliability)** **P0 — Fix first (CI reliability)**
1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops 1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops
2. Expand GitHub CI from single-crate coverage to workspace-grade verification — current `rust-ci.yml` runs `cargo fmt` and `cargo test -p rusty-claude-cli`, but misses broader `cargo test --workspace` coverage that already passes locally
3. Add release-grade binary workflow — repo has a Rust CLI and release intent, but no GitHub Actions path that builds tagged artifacts / checks release packaging before a publish step
4. Add container-first test/run docs — runtime detects Docker/Podman/container state, but docs do not show a canonical container workflow for `cargo test --workspace`, binary execution, or bind-mounted repo usage
5. Surface `doctor` / preflight diagnostics in onboarding docs and help — the CLI already has setup-diagnosis commands and branch preflight machinery, but they are not prominent enough in README/USAGE, so new users still ask manual setup questions instead of running a built-in health check first
6. Add branding/source-of-truth residue checks for docs — after repo migration, old org names can survive in badges, star-history URLs, and copied snippets; docs need a consistency pass or CI lint to catch stale branding automatically
7. Reconcile README product narrative with current repo reality — top-level docs now say the active workspace is Rust, but later sections still describe the repo as Python-first; users should not have to infer which implementation is canonical
8. Eliminate warning spam from first-run help/build path — `cargo run -p rusty-claude-cli -- --help` currently prints a wall of compile warnings before the actual help text, which pollutes the first-touch UX and hides the product surface behind unrelated noise
9. Promote `doctor` from slash-only to top-level CLI entrypoint — users naturally try `claw doctor`, but today it errors and tells them to enter a REPL or resume path first; healthcheck flows should be callable directly from the shell
10. Make machine-readable status commands actually machine-readable — `status` and `sandbox` accept the global `--output-format json` flag path, but currently still render prose tables, which breaks shell automation and agent-friendly health polling
11. Unify legacy config/skill namespaces in user-facing output — `skills` currently surfaces mixed project roots like `.codex` and `.claude`, which leaks historical layers into the current product and makes it unclear which config namespace is canonical
12. Honor JSON output on inventory commands like `skills` and `mcp` — these are exactly the commands agents and shell scripts want to inspect programmatically, but `--output-format json` still yields prose, forcing text scraping where structured inventory should exist
13. Audit `--output-format` contract across the whole CLI surface — current behavior is inconsistent by subcommand, so agents cannot trust the global flag without command-by-command probing; the format contract itself needs to become deterministic
**P1 — Next (integration wiring, unblocks verification)** **P1 — Next (integration wiring, unblocks verification)**
2. Add cross-module integration tests — **done**: 12 integration tests covering worker→recovery→policy, stale_branch→policy, green_contract→policy, reconciliation flows 2. Add cross-module integration tests — **done**: 12 integration tests covering worker→recovery→policy, stale_branch→policy, green_contract→policy, reconciliation flows
@@ -303,14 +291,8 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
14. **Config merge validation gap****done**: `config.rs` hook validation before deep-merge (+56 lines), malformed entries fail with source-path context instead of merged parse errors 14. **Config merge validation gap****done**: `config.rs` hook validation before deep-merge (+56 lines), malformed entries fail with source-path context instead of merged parse errors
15. **MCP manager discovery flaky test**`manager_discovery_report_keeps_healthy_servers_when_one_server_fails` has intermittent timing issues in CI; temporarily ignored, needs root cause fix 15. **MCP manager discovery flaky test**`manager_discovery_report_keeps_healthy_servers_when_one_server_fails` has intermittent timing issues in CI; temporarily ignored, needs root cause fix
16. **Commit provenance / worktree-aware push events** — clawhip build stream shows duplicate-looking commit messages and worktree-originated pushes without clear supersession indicators; add worktree/branch metadata to push events and de-dup superseded commits in build stream display
17. **Orphaned module integration audit**`session_control` is `pub mod` exported from `runtime` but has zero consumers across the entire workspace (no import, no call site outside its own file). `trust_resolver` types are re-exported from `lib.rs` but never instantiated outside unit tests. These modules implement core clawability contracts (session management, trust resolution) that are structurally dead — built but not wired into the CLI or tools crate. **Action:** audit all `pub mod` / `pub use` exports from `runtime` for actual call sites; either wire orphaned modules into the real execution path or demote to `pub(crate)` / `cfg(test)` to prevent false clawability surface.
18. **Context-window preflight gap** — claw-code auto-compacts only after cumulative input crosses a static `100_000`-token threshold, while provider requests derive `max_tokens` from a naive model-name heuristic (`opus` => 32k, else 64k) and do not appear to preflight `estimated_prompt_tokens + requested_output_tokens` against the selected models actual context window. Result: giant sessions can be sent upstream and fail hard with provider-side `input_exceeds_context_by_*` errors instead of local preflight compaction/rejection. **Action:** add a model-context registry + request-size preflight before provider call; if projected request exceeds context, emit a structured `context_window_blocked` event and auto-compact or force `/compact` before retry.
19. **Subcommand help falls through into runtime/API path** — direct dogfood shows `./target/debug/claw doctor --help` and `./target/debug/claw status --help` do not render local subcommand help. Instead they enter the request path, show `🦀 Thinking...`, then fail with `api returned 500 ... auth_unavailable: no auth available`. Help/usage surfaces must be pure local parsing and never require auth or provider reachability. **Action:** fix argv dispatch so `<subcommand> --help` is intercepted before runtime startup/API client initialization; add regression tests for `doctor --help`, `status --help`, and similar local-info commands.
**P3 — Swarm efficiency** **P3 — Swarm efficiency**
13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation 13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation
14. Commit provenance / worktree-aware push events — emit branch, worktree, superseded-by, and canonical commit lineage so parallel sessions stop producing duplicate-looking push summaries
## Suggested Session Split ## Suggested Session Split

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,6 @@ pub enum ApiError {
provider: &'static str, provider: &'static str,
env_vars: &'static [&'static str], env_vars: &'static [&'static str],
}, },
ContextWindowExceeded {
model: String,
estimated_input_tokens: u32,
requested_output_tokens: u32,
estimated_total_tokens: u32,
context_window_tokens: u32,
},
ExpiredOAuthToken, ExpiredOAuthToken,
Auth(String), Auth(String),
InvalidApiKeyEnv(VarError), InvalidApiKeyEnv(VarError),
@@ -55,7 +48,6 @@ impl ApiError {
Self::Api { retryable, .. } => *retryable, Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(), Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. } Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken | Self::ExpiredOAuthToken
| Self::Auth(_) | Self::Auth(_)
| Self::InvalidApiKeyEnv(_) | Self::InvalidApiKeyEnv(_)
@@ -75,16 +67,6 @@ impl Display for ApiError {
"missing {provider} credentials; export {} before calling the {provider} API", "missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ") env_vars.join(" or ")
), ),
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => write!(
f,
"context_window_blocked for {model}: estimated input {estimated_input_tokens} + requested output {requested_output_tokens} = {estimated_total_tokens} tokens exceeds the {context_window_tokens}-token context window; compact the session or reduce request size before retrying"
),
Self::ExpiredOAuthToken => { Self::ExpiredOAuthToken => {
write!( write!(
f, f,

View File

@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
use crate::error::ApiError; use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats}; use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{preflight_message_request, Provider, ProviderFuture}; use super::{Provider, ProviderFuture};
use crate::sse::SseParser; use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage}; use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -294,8 +294,6 @@ impl AnthropicClient {
} }
} }
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?; let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers()); let request_id = request_id_from_headers(response.headers());
let mut response = response let mut response = response
@@ -339,7 +337,6 @@ impl AnthropicClient {
&self, &self,
request: &MessageRequest, request: &MessageRequest,
) -> Result<MessageStream, ApiError> { ) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self let response = self
.send_with_retry(&request.clone().with_streaming()) .send_with_retry(&request.clone().with_streaming())
.await?; .await?;

View File

@@ -1,18 +1,14 @@
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use serde::Serialize;
use crate::error::ApiError; use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse}; use crate::types::{MessageRequest, MessageResponse};
pub mod anthropic; pub mod anthropic;
pub mod openai_compat; pub mod openai_compat;
#[allow(dead_code)]
pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>; pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>;
#[allow(dead_code)]
pub trait Provider { pub trait Provider {
type Stream; type Stream;
@@ -42,12 +38,6 @@ pub struct ProviderMetadata {
pub default_base_url: &'static str, pub default_base_url: &'static str,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelTokenLimit {
pub max_output_tokens: u32,
pub context_window_tokens: u32,
}
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
( (
"opus", "opus",
@@ -190,86 +180,17 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
#[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);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
}
#[must_use]
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() { if canonical.contains("opus") {
"claude-opus-4-6" => Some(ModelTokenLimit { 32_000
max_output_tokens: 32_000, } else {
context_window_tokens: 200_000, 64_000
}),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 200_000,
}),
"grok-3" | "grok-3-mini" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
_ => None,
} }
} }
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let estimated_input_tokens = estimate_message_request_input_tokens(request);
let estimated_total_tokens = estimated_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
return Err(ApiError::ContextWindowExceeded {
model: resolve_model_alias(&request.model),
estimated_input_tokens,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
let mut estimate = estimate_serialized_tokens(&request.messages);
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice));
estimate
}
fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
serde_json::to_vec(value)
.ok()
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use serde_json::json; use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
use crate::error::ApiError;
use crate::types::{
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
};
use super::{
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
resolve_model_alias, ProviderKind,
};
#[test] #[test]
fn resolves_grok_aliases() { fn resolves_grok_aliases() {
@@ -292,86 +213,4 @@ mod tests {
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);
} }
#[test]
fn returns_context_window_metadata_for_supported_models() {
assert_eq!(
model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered")
.context_window_tokens,
200_000
);
assert_eq!(
model_token_limit("grok-mini")
.expect("grok-mini should resolve to a registered model")
.context_window_tokens,
131_072
);
}
#[test]
fn preflight_blocks_requests_that_exceed_the_model_context_window() {
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": { "city": { "type": "string" } },
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
};
let error = preflight_message_request(&request)
.expect_err("oversized request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000);
assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {
model: "unknown-model".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
} }

View File

@@ -12,7 +12,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };
use super::{preflight_message_request, Provider, ProviderFuture}; use super::{Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -128,7 +128,6 @@ impl OpenAiCompatClient {
stream: false, stream: false,
..request.clone() ..request.clone()
}; };
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?; let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers()); let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?; let payload = response.json::<ChatCompletionResponse>().await?;
@@ -143,7 +142,6 @@ impl OpenAiCompatClient {
&self, &self,
request: &MessageRequest, request: &MessageRequest,
) -> Result<MessageStream, ApiError> { ) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self let response = self
.send_with_retry(&request.clone().with_streaming()) .send_with_retry(&request.clone().with_streaming())
.await?; .await?;

View File

@@ -103,41 +103,6 @@ async fn send_message_posts_json_and_parses_response() {
); );
} }
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test] #[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() { async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -4,10 +4,10 @@ use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock}; use std::sync::{Mutex as StdMutex, OnceLock};
use api::{ use api::{
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
ToolChoice, ToolDefinition, ToolDefinition,
}; };
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -63,42 +63,6 @@ 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_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(300_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test] #[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() { async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -2142,22 +2142,13 @@ pub fn handle_plugins_slash_command(
} }
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> { pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_agents_usage(None),
_ => render_agents_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents"); let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?; let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents)) Ok(render_agents_report(&agents))
} }
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)), Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
Some(args) => Ok(render_agents_usage(Some(args))), Some(args) => Ok(render_agents_usage(Some(args))),
} }
} }
@@ -2171,16 +2162,6 @@ pub fn handle_mcp_slash_command(
} }
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> { pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_skills_usage(None),
["install", ..] => render_skills_usage(Some("install")),
_ => render_skills_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
@@ -2196,7 +2177,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let install = install_skill(target, cwd)?; let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install)) Ok(render_skill_install_report(&install))
} }
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)), Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))), Some(args) => Ok(render_skills_usage(Some(args))),
} }
} }
@@ -2206,16 +2187,6 @@ fn render_mcp_report_for(
cwd: &Path, cwd: &Path,
args: Option<&str>, args: Option<&str>,
) -> Result<String, runtime::ConfigError> { ) -> Result<String, runtime::ConfigError> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_mcp_usage(None),
["show", ..] => render_mcp_usage(Some("show")),
_ => render_mcp_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
let runtime_config = loader.load()?; let runtime_config = loader.load()?;
@@ -2224,7 +2195,7 @@ fn render_mcp_report_for(
runtime_config.mcp().servers(), runtime_config.mcp().servers(),
)) ))
} }
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)), Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))), Some("show") => Ok(render_mcp_usage(Some("show"))),
Some(args) if args.split_whitespace().next() == Some("show") => { Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace(); let mut parts = args.split_whitespace();
@@ -3065,16 +3036,6 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
args.map(str::trim).filter(|value| !value.is_empty()) args.map(str::trim).filter(|value| !value.is_empty())
} }
fn is_help_arg(arg: &str) -> bool {
matches!(arg, "help" | "-h" | "--help")
}
fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
let parts = args.split_whitespace().collect::<Vec<_>>();
let help_index = parts.iter().position(|part| is_help_arg(part))?;
Some(parts[..help_index].to_vec())
}
fn render_agents_usage(unexpected: Option<&str>) -> String { fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![ let mut lines = vec![
"Agents".to_string(), "Agents".to_string(),
@@ -4044,17 +4005,7 @@ mod tests {
let skills_unexpected = let skills_unexpected =
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
assert!(skills_unexpected.contains("Unexpected show")); assert!(skills_unexpected.contains("Unexpected show help"));
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_install_help.contains("Unexpected install"));
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_unknown_help.contains("Unexpected show"));
let _ = fs::remove_dir_all(cwd); let _ = fs::remove_dir_all(cwd);
} }
@@ -4071,16 +4022,6 @@ mod tests {
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage"); super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
assert!(unexpected.contains("Unexpected show alpha beta")); assert!(unexpected.contains("Unexpected show alpha beta"));
let nested_help =
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(nested_help.contains("Unexpected show"));
let unknown_help =
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_help.contains("Unexpected inspect"));
let _ = fs::remove_dir_all(cwd); let _ = fs::remove_dir_all(cwd);
} }

View File

@@ -28,7 +28,6 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
/// Validate that a resolved path stays within the given workspace root. /// Validate that a resolved path stays within the given workspace root.
/// Returns the canonical path on success, or an error if the path escapes /// Returns the canonical path on success, or an error if the path escapes
/// the workspace boundary (e.g. via `../` traversal or symlink). /// the workspace boundary (e.g. via `../` traversal or symlink).
#[allow(dead_code)]
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> { fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
if !resolved.starts_with(workspace_root) { if !resolved.starts_with(workspace_root) {
return Err(io::Error::new( return Err(io::Error::new(
@@ -558,7 +557,6 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
} }
/// Read a file with workspace boundary enforcement. /// Read a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn read_file_in_workspace( pub fn read_file_in_workspace(
path: &str, path: &str,
offset: Option<usize>, offset: Option<usize>,
@@ -574,7 +572,6 @@ pub fn read_file_in_workspace(
} }
/// Write a file with workspace boundary enforcement. /// Write a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn write_file_in_workspace( pub fn write_file_in_workspace(
path: &str, path: &str,
content: &str, content: &str,
@@ -589,7 +586,6 @@ pub fn write_file_in_workspace(
} }
/// Edit a file with workspace boundary enforcement. /// Edit a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn edit_file_in_workspace( pub fn edit_file_in_workspace(
path: &str, path: &str,
old_string: &str, old_string: &str,
@@ -606,7 +602,6 @@ pub fn edit_file_in_workspace(
} }
/// Check whether a path is a symlink that resolves outside the workspace. /// Check whether a path is a symlink that resolves outside the workspace.
#[allow(dead_code)]
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> { pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
let metadata = fs::symlink_metadata(path)?; let metadata = fs::symlink_metadata(path)?;
if !metadata.is_symlink() { if !metadata.is_symlink() {

View File

@@ -114,12 +114,8 @@ impl LaneEvent {
#[must_use] #[must_use]
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self { pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
Self::new( Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
LaneEventName::Finished, .with_optional_detail(detail)
LaneEventStatus::Completed,
emitted_at,
)
.with_optional_detail(detail)
} }
#[must_use] #[must_use]
@@ -165,14 +161,19 @@ impl LaneEvent {
mod tests { mod tests {
use serde_json::json; use serde_json::json;
use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass}; use super::{
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
};
#[test] #[test]
fn canonical_lane_event_names_serialize_to_expected_wire_values() { fn canonical_lane_event_names_serialize_to_expected_wire_values() {
let cases = [ let cases = [
(LaneEventName::Started, "lane.started"), (LaneEventName::Started, "lane.started"),
(LaneEventName::Ready, "lane.ready"), (LaneEventName::Ready, "lane.ready"),
(LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"), (
LaneEventName::PromptMisdelivery,
"lane.prompt_misdelivery",
),
(LaneEventName::Blocked, "lane.blocked"), (LaneEventName::Blocked, "lane.blocked"),
(LaneEventName::Red, "lane.red"), (LaneEventName::Red, "lane.red"),
(LaneEventName::Green, "lane.green"), (LaneEventName::Green, "lane.green"),
@@ -192,10 +193,7 @@ mod tests {
]; ];
for (event, expected) in cases { for (event, expected) in cases {
assert_eq!( assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected));
serde_json::to_value(event).expect("serialize event"),
json!(expected)
);
} }
} }

View File

@@ -599,10 +599,7 @@ mod tests {
)); ));
match result { match result {
McpPhaseResult::Failure { McpPhaseResult::Failure { phase: failed_phase, error } => {
phase: failed_phase,
error,
} => {
assert_eq!(failed_phase, phase); assert_eq!(failed_phase, phase);
assert_eq!(error.phase, phase); assert_eq!(error.phase, phase);
assert_eq!( assert_eq!(

View File

@@ -360,10 +360,8 @@ impl McpServerManagerError {
} }
fn recoverable(&self) -> bool { fn recoverable(&self) -> bool {
!matches!( !matches!(self.lifecycle_phase(), McpLifecyclePhase::InitializeHandshake)
self.lifecycle_phase(), && matches!(self, Self::Transport { .. } | Self::Timeout { .. })
McpLifecyclePhase::InitializeHandshake
) && matches!(self, Self::Transport { .. } | Self::Timeout { .. })
} }
fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure { fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure {
@@ -419,9 +417,10 @@ impl McpServerManagerError {
("method".to_string(), (*method).to_string()), ("method".to_string(), (*method).to_string()),
("timeout_ms".to_string(), timeout_ms.to_string()), ("timeout_ms".to_string(), timeout_ms.to_string()),
]), ]),
Self::UnknownTool { qualified_name } => { Self::UnknownTool { qualified_name } => BTreeMap::from([(
BTreeMap::from([("qualified_tool".to_string(), qualified_name.clone())]) "qualified_tool".to_string(),
} qualified_name.clone(),
)]),
Self::UnknownServer { server_name } => { Self::UnknownServer { server_name } => {
BTreeMap::from([("server".to_string(), server_name.clone())]) BTreeMap::from([("server".to_string(), server_name.clone())])
} }
@@ -1426,10 +1425,11 @@ mod tests {
use crate::mcp_client::McpClientBootstrap; use crate::mcp_client::McpClientBootstrap;
use super::{ use super::{
spawn_mcp_stdio_process, unsupported_server_failed_server, JsonRpcId, JsonRpcRequest, spawn_mcp_stdio_process, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
JsonRpcResponse, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult, McpInitializeServerInfo,
McpInitializeServerInfo, McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpServerManager,
McpServerManager, McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams, McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
unsupported_server_failed_server,
}; };
use crate::McpLifecyclePhase; use crate::McpLifecyclePhase;
@@ -2698,10 +2698,7 @@ mod tests {
); );
assert!(!report.failed_servers[0].recoverable); assert!(!report.failed_servers[0].recoverable);
assert_eq!( assert_eq!(
report.failed_servers[0] report.failed_servers[0].context.get("method").map(String::as_str),
.context
.get("method")
.map(String::as_str),
Some("initialize") Some("initialize")
); );
assert!(report.failed_servers[0].error.contains("initialize")); assert!(report.failed_servers[0].error.contains("initialize"));

View File

@@ -4,7 +4,10 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize};
use crate::session::{Session, SessionError}; use crate::session::{Session, SessionError};
use crate::worker_boot::{Worker, WorkerReadySnapshot, WorkerRegistry, WorkerStatus};
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
pub const LEGACY_SESSION_EXTENSION: &str = "json"; pub const LEGACY_SESSION_EXTENSION: &str = "json";

View File

@@ -66,7 +66,11 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
&packet.reporting_contract, &packet.reporting_contract,
&mut errors, &mut errors,
); );
validate_required("escalation_policy", &packet.escalation_policy, &mut errors); validate_required(
"escalation_policy",
&packet.escalation_policy,
&mut errors,
);
for (index, test) in packet.acceptance_tests.iter().enumerate() { for (index, test) in packet.acceptance_tests.iter().enumerate() {
if test.trim().is_empty() { if test.trim().is_empty() {
@@ -142,9 +146,9 @@ mod tests {
assert!(error assert!(error
.errors() .errors()
.contains(&"repo must not be empty".to_string())); .contains(&"repo must not be empty".to_string()));
assert!(error assert!(error.errors().contains(
.errors() &"acceptance_tests contains an empty value at index 1".to_string()
.contains(&"acceptance_tests contains an empty value at index 1".to_string())); ));
} }
#[test] #[test]

View File

@@ -76,7 +76,11 @@ impl TaskRegistry {
} }
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task { pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(prompt.to_owned(), description.map(str::to_owned), None) self.create_task(
prompt.to_owned(),
description.map(str::to_owned),
None,
)
} }
pub fn create_from_packet( pub fn create_from_packet(

View File

@@ -257,9 +257,7 @@ impl WorkerRegistry {
let prompt_preview = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default()); let prompt_preview = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default());
let message = match observation.target { let message = match observation.target {
WorkerPromptTarget::Shell => { WorkerPromptTarget::Shell => {
format!( format!("worker prompt landed in shell instead of coding agent: {prompt_preview}")
"worker prompt landed in shell instead of coding agent: {prompt_preview}"
)
} }
WorkerPromptTarget::WrongTarget => format!( WorkerPromptTarget::WrongTarget => format!(
"worker prompt landed in the wrong target instead of {}: {}", "worker prompt landed in the wrong target instead of {}: {}",
@@ -314,9 +312,7 @@ impl WorkerRegistry {
worker.last_error = None; worker.last_error = None;
} }
if detect_ready_for_prompt(screen_text, &lowered) if detect_ready_for_prompt(screen_text, &lowered) && worker.status != WorkerStatus::ReadyForPrompt {
&& worker.status != WorkerStatus::ReadyForPrompt
{
worker.status = WorkerStatus::ReadyForPrompt; worker.status = WorkerStatus::ReadyForPrompt;
worker.prompt_in_flight = false; worker.prompt_in_flight = false;
if matches!( if matches!(
@@ -416,10 +412,7 @@ impl WorkerRegistry {
worker_id: worker.worker_id.clone(), worker_id: worker.worker_id.clone(),
status: worker.status, status: worker.status,
ready: worker.status == WorkerStatus::ReadyForPrompt, ready: worker.status == WorkerStatus::ReadyForPrompt,
blocked: matches!( blocked: matches!(worker.status, WorkerStatus::TrustRequired | WorkerStatus::Failed),
worker.status,
WorkerStatus::TrustRequired | 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(),
}) })

View File

@@ -49,7 +49,7 @@ use runtime::{
UsageTracker, UsageTracker,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::json;
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput}; use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
const DEFAULT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_MODEL: &str = "claude-opus-4-6";
@@ -117,14 +117,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::ResumeSession { CliAction::ResumeSession {
session_path, session_path,
commands, commands,
output_format, } => resume_session(&session_path, &commands),
} => resume_session(&session_path, &commands, output_format),
CliAction::Status { CliAction::Status {
model, model,
permission_mode, permission_mode,
output_format, } => print_status_snapshot(&model, permission_mode)?,
} => print_status_snapshot(&model, permission_mode, output_format)?, CliAction::Sandbox => print_sandbox_status_snapshot()?,
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
CliAction::Prompt { CliAction::Prompt {
prompt, prompt,
model, model,
@@ -167,16 +165,12 @@ enum CliAction {
ResumeSession { ResumeSession {
session_path: PathBuf, session_path: PathBuf,
commands: Vec<String>, commands: Vec<String>,
output_format: CliOutputFormat,
}, },
Status { Status {
model: String, model: String,
permission_mode: PermissionMode, permission_mode: PermissionMode,
output_format: CliOutputFormat,
},
Sandbox {
output_format: CliOutputFormat,
}, },
Sandbox,
Prompt { Prompt {
prompt: String, prompt: String,
model: String, model: String,
@@ -345,10 +339,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}); });
} }
if rest.first().map(String::as_str) == Some("--resume") { if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..], output_format); return parse_resume_args(&rest[1..]);
} }
if let Some(action) = if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override)
parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format)
{ {
return action; return action;
} }
@@ -399,7 +392,6 @@ fn parse_single_word_command_alias(
rest: &[String], rest: &[String],
model: &str, model: &str,
permission_mode_override: Option<PermissionMode>, permission_mode_override: Option<PermissionMode>,
output_format: CliOutputFormat,
) -> Option<Result<CliAction, String>> { ) -> Option<Result<CliAction, String>> {
if rest.len() != 1 { if rest.len() != 1 {
return None; return None;
@@ -411,9 +403,8 @@ fn parse_single_word_command_alias(
"status" => Some(Ok(CliAction::Status { "status" => Some(Ok(CliAction::Status {
model: model.to_string(), model: model.to_string(),
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
output_format,
})), })),
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })), "sandbox" => Some(Ok(CliAction::Sandbox)),
other => bare_slash_command_guidance(other).map(Err), other => bare_slash_command_guidance(other).map(Err),
} }
} }
@@ -707,7 +698,7 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
Ok(CliAction::PrintSystemPrompt { cwd, date }) Ok(CliAction::PrintSystemPrompt { cwd, date })
} }
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> { fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
Some(first) if looks_like_slash_command_token(first) => { Some(first) if looks_like_slash_command_token(first) => {
@@ -747,7 +738,6 @@ fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<
Ok(CliAction::ResumeSession { Ok(CliAction::ResumeSession {
session_path, session_path,
commands, commands,
output_format,
}) })
} }
@@ -938,7 +928,7 @@ fn print_version() {
println!("{}", render_version_report()); println!("{}", render_version_report());
} }
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { fn resume_session(session_path: &Path, commands: &[String]) {
let resolved_path = if session_path.exists() { let resolved_path = if session_path.exists() {
session_path.to_path_buf() session_path.to_path_buf()
} else { } else {
@@ -960,31 +950,15 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
}; };
if commands.is_empty() { if commands.is_empty() {
match output_format { println!(
CliOutputFormat::Text => { "Restored session from {} ({} messages).",
println!( resolved_path.display(),
"Restored session from {} ({} messages).", session.messages.len()
resolved_path.display(), );
session.messages.len()
);
}
CliOutputFormat::Json => {
println!(
"{}",
serialize_json_output(&json!({
"kind": "resume",
"session_file": resolved_path.display().to_string(),
"messages": session.messages.len(),
}))
.unwrap_or_else(|error| format!(r#"{{"kind":"error","message":"{error}"}}"#))
);
}
}
return; return;
} }
let mut session = session; let mut session = session;
let mut json_outputs = Vec::new();
for raw_command in commands { for raw_command in commands {
let command = match SlashCommand::parse(raw_command) { let command = match SlashCommand::parse(raw_command) {
Ok(Some(command)) => command, Ok(Some(command)) => command,
@@ -997,19 +971,14 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
std::process::exit(2); std::process::exit(2);
} }
}; };
match run_resume_command(&resolved_path, &session, &command, output_format) { match run_resume_command(&resolved_path, &session, &command) {
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: next_session, session: next_session,
message, message,
}) => { }) => {
session = next_session; session = next_session;
if let Some(message) = message { if let Some(message) = message {
match output_format { println!("{message}");
CliOutputFormat::Text => {
println!("{}", render_resume_text_output(&message))
}
CliOutputFormat::Json => json_outputs.push(message),
}
} }
} }
Err(error) => { Err(error) => {
@@ -1018,27 +987,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
} }
} }
} }
if matches!(output_format, CliOutputFormat::Json) {
let payload = if json_outputs.len() == 1 {
json_outputs.pop().expect("single json output")
} else {
Value::Array(json_outputs)
};
match serialize_json_output(&payload) {
Ok(rendered) => println!("{rendered}"),
Err(error) => {
eprintln!("failed to render json output: {error}");
std::process::exit(2);
}
}
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ResumeCommandOutcome { struct ResumeCommandOutcome {
session: Session, session: Session,
message: Option<Value>, message: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1363,28 +1317,16 @@ fn parse_git_status_metadata_for(
(project_root, branch) (project_root, branch)
} }
fn serialize_json_output(value: &Value) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(value)
}
fn render_resume_text_output(value: &Value) -> String {
value.get("message").and_then(Value::as_str).map_or_else(
|| serialize_json_output(value).unwrap_or_else(|_| value.to_string()),
ToString::to_string,
)
}
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn run_resume_command( fn run_resume_command(
session_path: &Path, session_path: &Path,
session: &Session, session: &Session,
command: &SlashCommand, command: &SlashCommand,
output_format: CliOutputFormat,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> { ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command { match command {
SlashCommand::Help => Ok(ResumeCommandOutcome { SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ "kind": "help", "message": render_repl_help() })), message: Some(render_repl_help()),
}), }),
SlashCommand::Compact => { SlashCommand::Compact => {
let result = runtime::compact_session( let result = runtime::compact_session(
@@ -1400,20 +1342,16 @@ fn run_resume_command(
result.compacted_session.save_to_path(session_path)?; result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: result.compacted_session, session: result.compacted_session,
message: Some(json!({ message: Some(format_compact_report(removed, kept, skipped)),
"kind": "compact",
"message": format_compact_report(removed, kept, skipped),
})),
}) })
} }
SlashCommand::Clear { confirm } => { SlashCommand::Clear { confirm } => {
if !confirm { if !confirm {
return Ok(ResumeCommandOutcome { return Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(
"kind": "clear", "clear: confirmation required; rerun with /clear --confirm".to_string(),
"message": "clear: confirmation required; rerun with /clear --confirm", ),
})),
}); });
} }
let backup_path = write_session_clear_backup(session, session_path)?; let backup_path = write_session_clear_backup(session, session_path)?;
@@ -1423,85 +1361,55 @@ fn run_resume_command(
cleared.save_to_path(session_path)?; cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: cleared, session: cleared,
message: Some(json!({ message: Some(format!(
"kind": "clear", "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
"message": format!( backup_path.display(),
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", backup_path.display(),
backup_path.display(), session_path.display()
backup_path.display(), )),
session_path.display()
),
})),
}) })
} }
SlashCommand::Status => { SlashCommand::Status => {
let tracker = UsageTracker::from_session(session); let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage(); let usage = tracker.cumulative_usage();
let status_usage = StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
};
let context = status_context(Some(session_path))?;
let status_json = status_report_json(
"restored-session",
status_usage,
default_permission_mode().as_str(),
&context,
);
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(match output_format { message: Some(format_status_report(
CliOutputFormat::Text => json!({ "restored-session",
"kind": "status-text", StatusUsage {
"message": format_status_report( message_count: session.messages.len(),
"restored-session", turns: tracker.turns(),
status_usage, latest: tracker.current_turn_usage(),
default_permission_mode().as_str(), cumulative: usage,
&context, estimated_tokens: 0,
), },
}), default_permission_mode().as_str(),
CliOutputFormat::Json => status_json, &status_context(Some(session_path))?,
}), )),
}) })
} }
SlashCommand::Sandbox => { SlashCommand::Sandbox => {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?; let runtime_config = loader.load()?;
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(match output_format { message: Some(format_sandbox_report(&resolve_sandbox_status(
CliOutputFormat::Text => json!({ runtime_config.sandbox(),
"kind": "sandbox-text", &cwd,
"message": format_sandbox_report(&sandbox_status), ))),
}),
CliOutputFormat::Json => json!({
"kind": "sandbox",
"sandbox": sandbox_status_json(&sandbox_status),
}),
}),
}) })
} }
SlashCommand::Cost => { SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage(); let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(format_cost_report(usage)),
"kind": "cost",
"message": format_cost_report(usage),
})),
}) })
} }
SlashCommand::Config { section } => Ok(ResumeCommandOutcome { SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(render_config_report(section.as_deref())?),
"kind": "config",
"message": render_config_report(section.as_deref())?,
})),
}), }),
SlashCommand::Mcp { action, target } => { SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
@@ -1513,75 +1421,51 @@ fn run_resume_command(
}; };
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
"kind": "mcp",
"message": handle_mcp_slash_command(args.as_deref(), &cwd)?,
})),
}) })
} }
SlashCommand::Memory => Ok(ResumeCommandOutcome { SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(render_memory_report()?),
"kind": "memory",
"message": render_memory_report()?,
})),
}), }),
SlashCommand::Init => Ok(ResumeCommandOutcome { SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(init_claude_md()?),
"kind": "init",
"message": init_claude_md()?,
})),
}), }),
SlashCommand::Diff => Ok(ResumeCommandOutcome { SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(render_diff_report_for(
"kind": "diff", session_path.parent().unwrap_or_else(|| Path::new(".")),
"message": render_diff_report_for( )?),
session_path.parent().unwrap_or_else(|| Path::new(".")),
)?,
})),
}), }),
SlashCommand::Version => Ok(ResumeCommandOutcome { SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(render_version_report()),
"kind": "version",
"message": render_version_report(),
})),
}), }),
SlashCommand::Export { path } => { SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?; let export_path = resolve_export_path(path.as_deref(), session)?;
fs::write(&export_path, render_export_text(session))?; fs::write(&export_path, render_export_text(session))?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(format!(
"kind": "export", "Export\n Result wrote transcript\n File {}\n Messages {}",
"message": format!( export_path.display(),
"Export\n Result wrote transcript\n File {}\n Messages {}", session.messages.len(),
export_path.display(), )),
session.messages.len(),
),
})),
}) })
} }
SlashCommand::Agents { args } => { SlashCommand::Agents { args } => {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
"kind": "agents",
"message": handle_agents_slash_command(args.as_deref(), &cwd)?,
})),
}) })
} }
SlashCommand::Skills { args } => { SlashCommand::Skills { args } => {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(json!({ message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
"kind": "skills",
"message": handle_skills_slash_command(args.as_deref(), &cwd)?,
})),
}) })
} }
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
@@ -1867,38 +1751,37 @@ impl RuntimeMcpState {
.into_iter() .into_iter()
.filter(|server_name| !failed_server_names.contains(server_name)) .filter(|server_name| !failed_server_names.contains(server_name))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let failed_servers = let failed_servers = discovery
discovery .failed_servers
.failed_servers .iter()
.iter() .map(|failure| runtime::McpFailedServer {
.map(|failure| runtime::McpFailedServer { server_name: failure.server_name.clone(),
server_name: failure.server_name.clone(), phase: runtime::McpLifecyclePhase::ToolDiscovery,
phase: runtime::McpLifecyclePhase::ToolDiscovery, error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ToolDiscovery,
Some(failure.server_name.clone()),
failure.error.clone(),
std::collections::BTreeMap::new(),
true,
),
})
.chain(discovery.unsupported_servers.iter().map(|server| {
runtime::McpFailedServer {
server_name: server.server_name.clone(),
phase: runtime::McpLifecyclePhase::ServerRegistration,
error: runtime::McpErrorSurface::new( error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ToolDiscovery, runtime::McpLifecyclePhase::ServerRegistration,
Some(failure.server_name.clone()), Some(server.server_name.clone()),
failure.error.clone(), server.reason.clone(),
std::collections::BTreeMap::new(), std::collections::BTreeMap::from([(
true, "transport".to_string(),
format!("{:?}", server.transport).to_ascii_lowercase(),
)]),
false,
), ),
}) }
.chain(discovery.unsupported_servers.iter().map(|server| { }))
runtime::McpFailedServer { .collect::<Vec<_>>();
server_name: server.server_name.clone(),
phase: runtime::McpLifecyclePhase::ServerRegistration,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ServerRegistration,
Some(server.server_name.clone()),
server.reason.clone(),
std::collections::BTreeMap::from([(
"transport".to_string(),
format!("{:?}", server.transport).to_ascii_lowercase(),
)]),
false,
),
}
}))
.collect::<Vec<_>>();
let degraded_report = (!failed_servers.is_empty()).then(|| { let degraded_report = (!failed_servers.is_empty()).then(|| {
runtime::McpDegradedReport::new( runtime::McpDegradedReport::new(
working_servers, working_servers,
@@ -3296,31 +3179,22 @@ fn render_repl_help() -> String {
fn print_status_snapshot( fn print_status_snapshot(
model: &str, model: &str,
permission_mode: PermissionMode, permission_mode: PermissionMode,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let usage = StatusUsage { println!(
message_count: 0, "{}",
turns: 0, format_status_report(
latest: TokenUsage::default(), model,
cumulative: TokenUsage::default(), StatusUsage {
estimated_tokens: 0, message_count: 0,
}; turns: 0,
let context = status_context(None)?; latest: TokenUsage::default(),
match output_format { cumulative: TokenUsage::default(),
CliOutputFormat::Text => println!( estimated_tokens: 0,
"{}", },
format_status_report(model, usage, permission_mode.as_str(), &context) permission_mode.as_str(),
), &status_context(None)?,
CliOutputFormat::Json => println!( )
"{}", );
serialize_json_output(&status_report_json(
model,
usage,
permission_mode.as_str(),
&context,
))?
),
}
Ok(()) Ok(())
} }
@@ -3418,61 +3292,6 @@ fn format_status_report(
) )
} }
fn status_report_json(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
) -> Value {
json!({
"kind": "status",
"model": model,
"permission_mode": permission_mode,
"messages": usage.message_count,
"turns": usage.turns,
"estimated_tokens": usage.estimated_tokens,
"usage": {
"latest": token_usage_json(usage.latest),
"cumulative": token_usage_json(usage.cumulative),
},
"workspace": {
"cwd": context.cwd.display().to_string(),
"project_root": context.project_root.as_ref().map(|path| path.display().to_string()),
"git_branch": context.git_branch.clone(),
"git_state": context.git_summary.headline(),
"changed_files": context.git_summary.changed_files,
"staged_files": context.git_summary.staged_files,
"unstaged_files": context.git_summary.unstaged_files,
"untracked_files": context.git_summary.untracked_files,
"session": status_session_label(context.session_path.as_deref()),
"config_files": {
"loaded": context.loaded_config_files,
"discovered": context.discovered_config_files,
},
"memory_files": context.memory_file_count,
"suggested_flow": ["/status", "/diff", "/commit"],
},
"sandbox": sandbox_status_json(&context.sandbox_status),
})
}
fn token_usage_json(usage: TokenUsage) -> Value {
json!({
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})
}
fn status_session_label(session_path: Option<&Path>) -> String {
session_path.map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string(),
)
}
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
format!( format!(
"Sandbox "Sandbox
@@ -3516,31 +3335,6 @@ fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
) )
} }
fn sandbox_status_json(status: &runtime::SandboxStatus) -> Value {
json!({
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"namespace_supported": status.namespace_supported,
"namespace_active": status.namespace_active,
"network_supported": status.network_supported,
"network_active": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts.clone(),
"in_container": status.in_container,
"container_markers": status.container_markers.clone(),
"fallback_reason": status.fallback_reason.clone(),
"requested": {
"enabled": status.requested.enabled,
"namespace_restrictions": status.requested.namespace_restrictions,
"network_isolation": status.requested.network_isolation,
"filesystem_mode": status.requested.filesystem_mode.as_str(),
"allowed_mounts": status.requested.allowed_mounts.clone(),
}
})
}
fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
format!( format!(
"Commit "Commit
@@ -3564,25 +3358,16 @@ fn format_commit_skipped_report() -> String {
.to_string() .to_string()
} }
fn print_sandbox_status_snapshot( fn print_sandbox_status_snapshot() -> Result<(), Box<dyn std::error::Error>> {
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader let runtime_config = loader
.load() .load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty()); .unwrap_or_else(|_| runtime::RuntimeConfig::empty());
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); println!(
match output_format { "{}",
CliOutputFormat::Text => println!("{}", format_sandbox_report(&sandbox_status)), format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd))
CliOutputFormat::Json => println!( );
"{}",
serialize_json_output(&json!({
"kind": "sandbox",
"sandbox": sandbox_status_json(&sandbox_status),
}))?
),
}
Ok(()) Ok(())
} }
@@ -5865,12 +5650,12 @@ mod tests {
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
permission_policy, print_help_to, push_output_block, render_config_report, permission_policy, print_help_to, push_output_block, render_config_report,
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help, render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
render_resume_text_output, render_resume_usage, resolve_model_alias, render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
resolve_session_reference, response_to_events, resume_supported_slash_commands, resume_supported_slash_commands, run_resume_command,
run_resume_command, slash_command_completion_candidates_with_sessions, status_context, slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand,
SlashCommand, StatusUsage, DEFAULT_MODEL, StatusUsage, DEFAULT_MODEL,
}; };
use api::{MessageResponse, OutputContentBlock, Usage}; use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{ use plugins::{
@@ -6278,36 +6063,11 @@ mod tests {
CliAction::Status { CliAction::Status {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
permission_mode: PermissionMode::DangerFullAccess, permission_mode: PermissionMode::DangerFullAccess,
output_format: CliOutputFormat::Text,
} }
); );
assert_eq!( assert_eq!(
parse_args(&["sandbox".to_string()]).expect("sandbox should parse"), parse_args(&["sandbox".to_string()]).expect("sandbox should parse"),
CliAction::Sandbox { CliAction::Sandbox
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_json_output_for_status_and_sandbox_aliases() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parse_args(&["--output-format=json".to_string(), "status".to_string()])
.expect("json status should parse"),
CliAction::Status {
model: DEFAULT_MODEL.to_string(),
permission_mode: PermissionMode::DangerFullAccess,
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&["--output-format=json".to_string(), "sandbox".to_string()])
.expect("json sandbox should parse"),
CliAction::Sandbox {
output_format: CliOutputFormat::Json,
}
); );
} }
@@ -6413,7 +6173,6 @@ mod tests {
CliAction::ResumeSession { CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"), session_path: PathBuf::from("session.jsonl"),
commands: vec!["/compact".to_string()], commands: vec!["/compact".to_string()],
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6425,7 +6184,6 @@ mod tests {
CliAction::ResumeSession { CliAction::ResumeSession {
session_path: PathBuf::from("latest"), session_path: PathBuf::from("latest"),
commands: vec![], commands: vec![],
output_format: CliOutputFormat::Text,
} }
); );
assert_eq!( assert_eq!(
@@ -6434,7 +6192,6 @@ mod tests {
CliAction::ResumeSession { CliAction::ResumeSession {
session_path: PathBuf::from("latest"), session_path: PathBuf::from("latest"),
commands: vec!["/status".to_string()], commands: vec!["/status".to_string()],
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6457,7 +6214,6 @@ mod tests {
"/compact".to_string(), "/compact".to_string(),
"/cost".to_string(), "/cost".to_string(),
], ],
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6488,7 +6244,6 @@ mod tests {
"/export notes.txt".to_string(), "/export notes.txt".to_string(),
"/clear --confirm".to_string(), "/clear --confirm".to_string(),
], ],
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6507,25 +6262,6 @@ mod tests {
CliAction::ResumeSession { CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"), session_path: PathBuf::from("session.jsonl"),
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()], commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_json_output_for_resumed_status_queries() {
let args = vec![
"--output-format=json".to_string(),
"--resume".to_string(),
"session.jsonl".to_string(),
"/status".to_string(),
];
assert_eq!(
parse_args(&args).expect("json resume status should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec!["/status".to_string()],
output_format: CliOutputFormat::Json,
} }
); );
} }
@@ -7046,16 +6782,10 @@ UU conflicted.rs",
let session = Session::load_from_path(&session_path).expect("session should load"); let session = Session::load_from_path(&session_path).expect("session should load");
let outcome = with_current_dir(&root, || { let outcome = with_current_dir(&root, || {
run_resume_command( run_resume_command(&session_path, &session, &SlashCommand::Diff)
&session_path, .expect("resume diff should work")
&session,
&SlashCommand::Diff,
CliOutputFormat::Text,
)
.expect("resume diff should work")
}); });
let message = let message = outcome.message.expect("diff message should exist");
render_resume_text_output(&outcome.message.expect("diff message should exist"));
assert!(message.contains("Unstaged changes:")); assert!(message.contains("Unstaged changes:"));
assert!(message.contains("tracked.txt")); assert!(message.contains("tracked.txt"));
@@ -7779,12 +7509,8 @@ UU conflicted.rs",
let runtime_config = loader.load().expect("runtime config should load"); let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load"); .expect("runtime plugin state should load");
let mut executor = CliToolExecutor::new( let mut executor =
None, CliToolExecutor::new(None, false, state.tool_registry.clone(), state.mcp_state.clone());
false,
state.tool_registry.clone(),
state.mcp_state.clone(),
);
let search_output = executor let search_output = executor
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#) .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)

View File

@@ -160,42 +160,6 @@ fn config_command_loads_defaults_from_standard_config_locations() {
fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
} }
#[test]
fn nested_help_flags_render_usage_instead_of_falling_through() {
let temp_dir = unique_temp_dir("nested-help");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let mcp_output = command_in(&temp_dir)
.args(["mcp", "show", "--help"])
.output()
.expect("claw should launch");
assert_success(&mcp_output);
let mcp_stdout = String::from_utf8(mcp_output.stdout).expect("stdout should be utf8");
assert!(mcp_stdout.contains("Usage /mcp [list|show <server>|help]"));
assert!(mcp_stdout.contains("Unexpected show"));
assert!(!mcp_stdout.contains("server `--help` is not configured"));
let skills_output = command_in(&temp_dir)
.args(["skills", "install", "--help"])
.output()
.expect("claw should launch");
assert_success(&skills_output);
let skills_stdout = String::from_utf8(skills_output.stdout).expect("stdout should be utf8");
assert!(skills_stdout.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_stdout.contains("Unexpected install"));
let unknown_output = command_in(&temp_dir)
.args(["mcp", "inspect", "--help"])
.output()
.expect("claw should launch");
assert_success(&unknown_output);
let unknown_stdout = String::from_utf8(unknown_output.stdout).expect("stdout should be utf8");
assert!(unknown_stdout.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_stdout.contains("Unexpected inspect"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
fn command_in(cwd: &Path) -> Command { fn command_in(cwd: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(cwd); command.current_dir(cwd);

View File

@@ -7,7 +7,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use runtime::ContentBlock; use runtime::ContentBlock;
use runtime::Session; use runtime::Session;
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -222,52 +221,6 @@ fn resume_latest_restores_the_most_recent_managed_session() {
assert!(stdout.contains(newer_path.to_str().expect("utf8 path"))); assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
} }
#[test]
fn resumed_status_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-status-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = Session::new();
session
.push_user_text("resume status json fixture")
.expect("session write should succeed");
session
.save_to_path(&session_path)
.expect("session should persist");
// when
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/status",
],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value =
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["messages"], 1);
assert_eq!(
parsed["workspace"]["session"],
session_path.to_str().expect("utf8 path")
);
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output { fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
run_claw_with_env(current_dir, args, &[]) run_claw_with_env(current_dir, args, &[])
} }

View File

@@ -16,10 +16,9 @@ use runtime::{
use crate::AgentOutput; use crate::AgentOutput;
/// Detects if a lane should be automatically marked as completed. /// Detects if a lane should be automatically marked as completed.
/// ///
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met, /// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
/// `None` if lane should remain active. /// `None` if lane should remain active.
#[allow(dead_code)]
pub(crate) fn detect_lane_completion( pub(crate) fn detect_lane_completion(
output: &AgentOutput, output: &AgentOutput,
test_green: bool, test_green: bool,
@@ -29,29 +28,29 @@ pub(crate) fn detect_lane_completion(
if output.error.is_some() { if output.error.is_some() {
return None; return None;
} }
// Must have finished status // Must have finished status
if !output.status.eq_ignore_ascii_case("completed") if !output.status.eq_ignore_ascii_case("completed")
&& !output.status.eq_ignore_ascii_case("finished") && !output.status.eq_ignore_ascii_case("finished")
{ {
return None; return None;
} }
// Must have no current blocker // Must have no current blocker
if output.current_blocker.is_some() { if output.current_blocker.is_some() {
return None; return None;
} }
// Must have green tests // Must have green tests
if !test_green { if !test_green {
return None; return None;
} }
// Must have pushed code // Must have pushed code
if !has_pushed { if !has_pushed {
return None; return None;
} }
// All conditions met — create completed context // All conditions met — create completed context
Some(LaneContext { Some(LaneContext {
lane_id: output.agent_id.clone(), lane_id: output.agent_id.clone(),
@@ -66,8 +65,9 @@ pub(crate) fn detect_lane_completion(
} }
/// Evaluates policy actions for a completed lane. /// Evaluates policy actions for a completed lane.
#[allow(dead_code)] pub(crate) fn evaluate_completed_lane(
pub(crate) fn evaluate_completed_lane(context: &LaneContext) -> Vec<PolicyAction> { context: &LaneContext,
) -> Vec<PolicyAction> {
let engine = PolicyEngine::new(vec![ let engine = PolicyEngine::new(vec![
PolicyRule::new( PolicyRule::new(
"closeout-completed-lane", "closeout-completed-lane",
@@ -85,7 +85,7 @@ pub(crate) fn evaluate_completed_lane(context: &LaneContext) -> Vec<PolicyAction
5, 5,
), ),
]); ]);
evaluate(&engine, context) evaluate(&engine, context)
} }
@@ -112,53 +112,53 @@ mod tests {
error: None, error: None,
} }
} }
#[test] #[test]
fn detects_completion_when_all_conditions_met() { fn detects_completion_when_all_conditions_met() {
let output = test_output(); let output = test_output();
let result = detect_lane_completion(&output, true, true); let result = detect_lane_completion(&output, true, true);
assert!(result.is_some()); assert!(result.is_some());
let context = result.unwrap(); let context = result.unwrap();
assert!(context.completed); assert!(context.completed);
assert_eq!(context.green_level, 3); assert_eq!(context.green_level, 3);
assert_eq!(context.blocker, LaneBlocker::None); assert_eq!(context.blocker, LaneBlocker::None);
} }
#[test] #[test]
fn no_completion_when_error_present() { fn no_completion_when_error_present() {
let mut output = test_output(); let mut output = test_output();
output.error = Some("Build failed".to_string()); output.error = Some("Build failed".to_string());
let result = detect_lane_completion(&output, true, true); let result = detect_lane_completion(&output, true, true);
assert!(result.is_none()); assert!(result.is_none());
} }
#[test] #[test]
fn no_completion_when_not_finished() { fn no_completion_when_not_finished() {
let mut output = test_output(); let mut output = test_output();
output.status = "Running".to_string(); output.status = "Running".to_string();
let result = detect_lane_completion(&output, true, true); let result = detect_lane_completion(&output, true, true);
assert!(result.is_none()); assert!(result.is_none());
} }
#[test] #[test]
fn no_completion_when_tests_not_green() { fn no_completion_when_tests_not_green() {
let output = test_output(); let output = test_output();
let result = detect_lane_completion(&output, false, true); let result = detect_lane_completion(&output, false, true);
assert!(result.is_none()); assert!(result.is_none());
} }
#[test] #[test]
fn no_completion_when_not_pushed() { fn no_completion_when_not_pushed() {
let output = test_output(); let output = test_output();
let result = detect_lane_completion(&output, true, false); let result = detect_lane_completion(&output, true, false);
assert!(result.is_none()); assert!(result.is_none());
} }
#[test] #[test]
fn evaluate_triggers_closeout_for_completed_lane() { fn evaluate_triggers_closeout_for_completed_lane() {
let context = LaneContext { let context = LaneContext {
@@ -171,9 +171,9 @@ mod tests {
completed: true, completed: true,
reconciled: false, reconciled: false,
}; };
let actions = evaluate_completed_lane(&context); let actions = evaluate_completed_lane(&context);
assert!(actions.contains(&PolicyAction::CloseoutLane)); assert!(actions.contains(&PolicyAction::CloseoutLane));
assert!(actions.contains(&PolicyAction::CleanupSession)); assert!(actions.contains(&PolicyAction::CleanupSession));
} }

View File

@@ -17,6 +17,7 @@ use runtime::{
permission_enforcer::{EnforcementResult, PermissionEnforcer}, permission_enforcer::{EnforcementResult, PermissionEnforcer},
read_file, read_file,
summary_compression::compress_summary_text, summary_compression::compress_summary_text,
TaskPacket,
task_registry::TaskRegistry, task_registry::TaskRegistry,
team_cron_registry::{CronRegistry, TeamRegistry}, team_cron_registry::{CronRegistry, TeamRegistry},
worker_boot::{WorkerReadySnapshot, WorkerRegistry}, worker_boot::{WorkerReadySnapshot, WorkerRegistry},
@@ -24,7 +25,7 @@ use runtime::{
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput, BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent,
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, RuntimeError, Session, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -1877,25 +1878,27 @@ fn branch_divergence_output(
dangerously_disable_sandbox: None, dangerously_disable_sandbox: None,
return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()), return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()),
no_output_expected: Some(false), no_output_expected: Some(false),
structured_content: Some(vec![serde_json::to_value( structured_content: Some(vec![
LaneEvent::new( serde_json::to_value(
LaneEventName::BranchStaleAgainstMain, LaneEvent::new(
LaneEventStatus::Blocked, LaneEventName::BranchStaleAgainstMain,
iso8601_now(), LaneEventStatus::Blocked,
iso8601_now(),
)
.with_failure_class(LaneFailureClass::BranchDivergence)
.with_detail(stderr.clone())
.with_data(json!({
"branch": branch,
"mainRef": main_ref,
"commitsBehind": commits_behind,
"commitsAhead": commits_ahead,
"missingCommits": missing_fixes,
"blockedCommand": command,
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
})),
) )
.with_failure_class(LaneFailureClass::BranchDivergence) .expect("lane event should serialize"),
.with_detail(stderr.clone()) ]),
.with_data(json!({
"branch": branch,
"mainRef": main_ref,
"commitsBehind": commits_behind,
"commitsAhead": commits_ahead,
"missingCommits": missing_fixes,
"blockedCommand": command,
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
})),
)
.expect("lane event should serialize")]),
persisted_output_path: None, persisted_output_path: None,
persisted_output_size: None, persisted_output_size: None,
sandbox_status: None, sandbox_status: None,
@@ -3294,12 +3297,12 @@ fn persist_agent_terminal_state(
next_manifest.current_blocker = blocker.clone(); next_manifest.current_blocker = blocker.clone();
next_manifest.error = error; next_manifest.error = error;
if let Some(blocker) = blocker { if let Some(blocker) = blocker {
next_manifest next_manifest.lane_events.push(
.lane_events LaneEvent::blocked(iso8601_now(), &blocker),
.push(LaneEvent::blocked(iso8601_now(), &blocker)); );
next_manifest next_manifest.lane_events.push(
.lane_events LaneEvent::failed(iso8601_now(), &blocker),
.push(LaneEvent::failed(iso8601_now(), &blocker)); );
} else { } else {
next_manifest.current_blocker = None; next_manifest.current_blocker = None;
let compressed_detail = result let compressed_detail = result
@@ -4949,8 +4952,8 @@ mod tests {
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure, agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName,
SubagentToolExecutor, LaneFailureClass, SubagentToolExecutor,
}; };
use api::OutputContentBlock; use api::OutputContentBlock;
use runtime::{ use runtime::{
@@ -5974,10 +5977,7 @@ mod tests {
"gateway routing rejected the request", "gateway routing rejected the request",
LaneFailureClass::GatewayRouting, LaneFailureClass::GatewayRouting,
), ),
( ("tool failed: denied tool execution from hook", LaneFailureClass::ToolRuntime),
"tool failed: denied tool execution from hook",
LaneFailureClass::ToolRuntime,
),
("thread creation failed", LaneFailureClass::Infra), ("thread creation failed", LaneFailureClass::Infra),
]; ];
@@ -6000,17 +6000,11 @@ mod tests {
(LaneEventName::MergeReady, "lane.merge.ready"), (LaneEventName::MergeReady, "lane.merge.ready"),
(LaneEventName::Finished, "lane.finished"), (LaneEventName::Finished, "lane.finished"),
(LaneEventName::Failed, "lane.failed"), (LaneEventName::Failed, "lane.failed"),
( (LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main"),
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
]; ];
for (event, expected) in cases { for (event, expected) in cases {
assert_eq!( assert_eq!(serde_json::to_value(event).expect("serialize lane event"), json!(expected));
serde_json::to_value(event).expect("serialize lane event"),
json!(expected)
);
} }
} }