Compare commits

...

8 Commits

Author SHA1 Message Date
Yeachan-Heo
73bf52467d Add tagged binary release workflow 2026-04-05 17:39:43 +00:00
Yeachan-Heo
31163be347 style: cargo fmt 2026-04-05 16:56:48 +00:00
Yeachan-Heo
eb4d3b11ee merge fix/p2-19-subcommand-help-fallthrough 2026-04-05 16:54:59 +00:00
Yeachan-Heo
9bd7a78ca8 Merge branch 'fix/p2-18-context-window-preflight' 2026-04-05 16:54:45 +00:00
Yeachan-Heo
24d8f916c8 merge fix/p0-10-json-status 2026-04-05 16:54:38 +00:00
Yeachan-Heo
30883bddbd Keep doctor and local help paths shell-native
Promote doctor into a real top-level CLI action, reuse the same local report for resumed and REPL doctor invocations, and intercept doctor/status/sandbox help flags before prompt-mode dispatch. The parser change also closes the help fallthrough that previously wandered into runtime startup for local-info commands.

Constraint: Preserve prompt shorthand for normal multi-word text input while fixing exact local subcommand help paths
Rejected: Route \7⠋ 🦀 Thinking...8✘  Request failed
 through prompt/slash guidance | still shells out through the wrong surface and keeps health checks hidden
Rejected: Reuse the status report as doctor output | status does not explain auth/config health or expose a dedicated diagnostic summary
Confidence: high
Scope-risk: narrow
Directive: Keep doctor local-only unless an explicit network probe is intentionally added and separately tested
Tested: cargo build -p rusty-claude-cli; cargo test -p rusty-claude-cli; cargo run -p rusty-claude-cli -- doctor --help; CLAW_CONFIG_HOME=/tmp/tmp.7pm9SVzOPN ANTHROPIC_API_KEY= ANTHROPIC_AUTH_TOKEN= cargo run -p rusty-claude-cli -- doctor
Not-tested: direct /doctor outside the REPL remains interactive-only
2026-04-05 16:44:36 +00:00
Yeachan-Heo
1a2fa1581e Keep status JSON machine-readable for automation
The global --output-format json flag already reached prompt-mode responses, but
status and sandbox still bypassed that path and printed human-readable tables.
This change threads the selected output format through direct command aliases
and resumed slash-command execution so status queries emit valid structured
JSON instead of mixed prose.

It also adds end-to-end regression coverage for direct status/sandbox JSON
and resumed /status JSON so shell automation can rely on stable parsing.

Constraint: Global output formatting must stay compatible with existing text-mode reports
Rejected: Require callers to scrape text status tables | fragile and breaks automation
Confidence: high
Scope-risk: narrow
Directive: New direct commands that honor --output-format should thread the format through CliAction and resumed slash execution paths
Tested: cargo build -p rusty-claude-cli
Tested: cargo test -p rusty-claude-cli -- --nocapture
Tested: cargo test --workspace
Tested: cargo run -q -p rusty-claude-cli -- --output-format json status
Tested: cargo run -q -p rusty-claude-cli -- --output-format json sandbox
Not-tested: cargo clippy --workspace --all-targets -- -D warnings (fails in pre-existing runtime files unrelated to this change)
2026-04-05 16:41:02 +00:00
Yeachan-Heo
fa72cd665e Block oversized requests before providers hard-fail
The runtime already tracked rough token estimates for compaction, but provider-bound
requests still relied on naive model output limits and could be sent upstream even
when the selected model could not fit the estimated prompt plus requested output.

This adds a small model token/context registry in the API layer, estimates request
size from the serialized prompt payload, and fails locally with a dedicated
context-window error before Anthropic or xAI calls are made. Focused integration
coverage asserts the preflight fires before any HTTP request leaves the process.

Constraint: Keep the first pass minimal and reusable across both Anthropic and OpenAI-compatible providers
Rejected: Auto-compact-and-retry in the same patch | broader control-flow change than the requested minimal preflight
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Expand the model registry before enabling preflight for additional providers or aliases
Tested: cargo build -p api -p tools -p rusty-claude-cli; cargo test -p api
Not-tested: End-to-end CLI auto-compaction or retry behavior after a local context_window_blocked failure
2026-04-05 16:39:58 +00:00
23 changed files with 889 additions and 229 deletions

68
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
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

BIN
assets/sigrid-photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,2 @@
{"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

@@ -0,0 +1,2 @@
{"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

@@ -0,0 +1,2 @@
{"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

@@ -0,0 +1,2 @@
{"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,6 +8,13 @@ pub enum ApiError {
provider: &'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,
Auth(String),
InvalidApiKeyEnv(VarError),
@@ -48,6 +55,7 @@ impl ApiError {
Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
@@ -67,6 +75,16 @@ impl Display for ApiError {
"missing {provider} credentials; export {} before calling the {provider} API",
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 => {
write!(
f,

View File

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

View File

@@ -1,6 +1,8 @@
use std::future::Future;
use std::pin::Pin;
use serde::Serialize;
use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse};
@@ -40,6 +42,12 @@ pub struct ProviderMetadata {
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)] = &[
(
"opus",
@@ -182,17 +190,86 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
#[must_use]
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);
if canonical.contains("opus") {
32_000
} else {
64_000
match canonical.as_str() {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_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)]
mod tests {
use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
use serde_json::json;
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]
fn resolves_grok_aliases() {
@@ -215,4 +292,86 @@ mod tests {
assert_eq!(max_tokens_for_model("opus"), 32_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,
};
use super::{Provider, ProviderFuture};
use super::{preflight_message_request, Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -128,6 +128,7 @@ impl OpenAiCompatClient {
stream: false,
..request.clone()
};
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?;
@@ -142,6 +143,7 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;

View File

@@ -103,6 +103,41 @@ 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]
async fn send_message_applies_request_profile_and_records_telemetry() {
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 api::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -63,6 +63,42 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
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]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::session::{Session, SessionError};
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";

View File

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

View File

@@ -76,11 +76,7 @@ impl TaskRegistry {
}
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(

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use runtime::ContentBlock;
use runtime::Session;
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -221,6 +222,52 @@ fn resume_latest_restores_the_most_recent_managed_session() {
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 {
run_claw_with_env(current_dir, args, &[])
}

View File

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

View File

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