Compare commits

...

8 Commits

Author SHA1 Message Date
Yeachan-Heo
04932997ff Expand CI coverage to the full Rust workspace 2026-04-05 17:40:31 +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
1f53d961ff Route nested CLI help requests to usage instead of operand fallthrough
The direct CLI wrappers for agents, skills, and mcp treated nested help flags as ordinary operands. That made commands like `claw mcp show --help` report a missing server and `claw skills install --help` fall into filesystem install logic instead of surfacing usage.

This change normalizes help-path arguments before dispatch so nested help stays on the help path. The regression tests cover both handler-level behavior and end-to-end CLI output for nested help and unknown subcommands with trailing help flags.

Constraint: Keep the fix scoped to direct CLI slash-command wrappers without changing unrelated parser behavior
Rejected: Rework top-level argument parsing for all subcommands | broader risk than needed for the regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more nested subcommands are added, extend the help-path normalization table before relying on raw operand dispatch
Tested: cargo build -p commands -p rusty-claude-cli
Tested: cargo test -p commands -p rusty-claude-cli
Not-tested: cargo clippy -p commands -p rusty-claude-cli --all-targets --no-deps -- -D warnings (pre-existing warnings in untouched files block clean run)
2026-04-05 16:38:43 +00:00
19 changed files with 674 additions and 226 deletions

View File

@@ -43,8 +43,8 @@ jobs:
- name: Check formatting
run: cargo fmt --all --check
test-rusty-claude-cli:
name: cargo test -p rusty-claude-cli
test-workspace:
name: cargo test --workspace
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -52,5 +52,19 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Run crate tests
run: cargo test -p rusty-claude-cli
- name: Run workspace tests
run: cargo test --workspace
clippy-workspace:
name: cargo clippy --workspace
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Run workspace clippy
run: cargo clippy --workspace

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

@@ -2142,13 +2142,22 @@ pub fn handle_plugins_slash_command(
}
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) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
}
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Ok(render_agents_usage(Some(args))),
}
}
@@ -2162,6 +2171,16 @@ pub fn handle_mcp_slash_command(
}
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) {
None | Some("list") => {
let roots = discover_skill_roots(cwd);
@@ -2177,7 +2196,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))),
}
}
@@ -2187,6 +2206,16 @@ fn render_mcp_report_for(
cwd: &Path,
args: Option<&str>,
) -> 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) {
None | Some("list") => {
let runtime_config = loader.load()?;
@@ -2195,7 +2224,7 @@ fn render_mcp_report_for(
runtime_config.mcp().servers(),
))
}
Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))),
Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace();
@@ -3036,6 +3065,16 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
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 {
let mut lines = vec![
"Agents".to_string(),
@@ -4005,7 +4044,17 @@ mod tests {
let skills_unexpected =
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
assert!(skills_unexpected.contains("Unexpected show help"));
assert!(skills_unexpected.contains("Unexpected show"));
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);
}
@@ -4022,6 +4071,16 @@ mod tests {
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
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);
}

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

@@ -160,6 +160,42 @@ fn config_command_loads_defaults_from_standard_config_locations() {
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 {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(cwd);

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)
);
}
}