Keep resumed JSON command surfaces machine-readable

Resumed slash dispatch was still dropping back to prose for several JSON-capable local commands, which forced automation to special-case direct CLI invocations versus --resume flows. This routes resumed local-command handlers through the same structured JSON payloads used by direct status, sandbox, inventory, version, and init commands, and records the inventory parity audit result in the roadmap.

Constraint: Text-mode resumed output must stay unchanged for existing shell users
Rejected: Teach callers to scrape resumed text output | brittle and defeats the JSON contract
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When a direct local command has a JSON renderer, keep resumed slash dispatch on the same serializer instead of adding one-off format branches
Tested: cargo fmt --check; cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Live provider-backed REPL resume flows outside the local test harness
This commit is contained in:
Yeachan-Heo
2026-04-06 01:44:30 +00:00
parent 53d6909b9b
commit fe4da2aa65
4 changed files with 233 additions and 61 deletions

View File

@@ -311,6 +311,8 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
26. **Resumed `/sandbox` JSON parity gap****done**: direct `claw sandbox --output-format json` already emitted structured JSON, but resumed `claw --output-format json --resume <session> /sandbox` still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. The resumed `/sandbox` path now reuses the same JSON envelope as the direct CLI command, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs`.
27. **Resumed inventory JSON parity gap for `/mcp` and `/skills`****done**: resumed slash-command inventory calls now honor `--output-format json` via the same structured renderers as direct `claw mcp` / `claw skills`, with regression coverage for resumed `list` output under an isolated config home.
**P3 — Swarm efficiency**
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them

View File

@@ -1688,23 +1688,25 @@ fn print_system_prompt(
}
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_version_report();
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "version",
"message": report,
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
}))?
),
CliOutputFormat::Text => println!("{}", render_version_report()),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&version_json_value())?);
}
}
Ok(())
}
fn version_json_value() -> serde_json::Value {
json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
})
}
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
let resolved_path = if session_path.exists() {
session_path.to_path_buf()
@@ -1752,33 +1754,21 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
Ok(ResumeCommandOutcome {
session: next_session,
message,
json,
}) => {
session = next_session;
if let Some(message) = message {
if output_format == CliOutputFormat::Json
&& matches!(command, SlashCommand::Status)
{
let tracker = UsageTracker::from_session(&session);
let context = status_context(Some(&resolved_path)).expect("status context");
let value = status_json_value(
"restored-session",
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: tracker.cumulative_usage(),
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&context,
);
if output_format == CliOutputFormat::Json {
if let Some(value) = json {
println!(
"{}",
serde_json::to_string_pretty(&value).expect("status json")
serde_json::to_string_pretty(&value)
.expect("resume command json output")
);
} else {
} else if let Some(message) = message {
println!("{message}");
}
} else if let Some(message) = message {
println!("{message}");
}
}
Err(error) => {
@@ -1793,6 +1783,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
json: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
@@ -2127,6 +2118,7 @@ fn run_resume_command(
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: None,
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
@@ -2143,6 +2135,7 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
json: None,
})
}
SlashCommand::Clear { confirm } => {
@@ -2152,6 +2145,7 @@ fn run_resume_command(
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
json: None,
});
}
let backup_path = write_session_clear_backup(session, session_path)?;
@@ -2167,11 +2161,13 @@ fn run_resume_command(
backup_path.display(),
session_path.display()
)),
json: None,
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
let context = status_context(Some(session_path))?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
@@ -2184,7 +2180,19 @@ fn run_resume_command(
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
&context,
)),
json: Some(status_json_value(
"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(),
&context,
)),
})
}
@@ -2192,12 +2200,11 @@ fn run_resume_command(
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let 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(format_sandbox_report(&status)),
json: Some(sandbox_json_value(&status)),
})
}
SlashCommand::Cost => {
@@ -2205,11 +2212,13 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: None,
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
json: None,
}),
SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?;
@@ -2222,25 +2231,33 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
json: None,
}),
SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(init_claude_md()?),
}),
SlashCommand::Init => {
let message = init_claude_md()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&message)),
})
}
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report_for(
session_path.parent().unwrap_or_else(|| Path::new(".")),
)?),
json: None,
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
json: Some(version_json_value()),
}),
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
@@ -2252,6 +2269,7 @@ fn run_resume_command(
export_path.display(),
session.messages.len(),
)),
json: None,
})
}
SlashCommand::Agents { args } => {
@@ -2259,6 +2277,7 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: None,
})
}
SlashCommand::Skills { args } => {
@@ -2266,11 +2285,13 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Doctor => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_doctor_report()?.render()),
json: None,
}),
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
SlashCommand::Bughunter { .. }
@@ -4259,27 +4280,31 @@ fn print_sandbox_status_snapshot(
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"in_container": status.in_container,
"requested_namespace": status.requested.namespace_restrictions,
"active_namespace": status.namespace_active,
"requested_network": status.requested.network_isolation,
"active_network": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts,
"markers": status.container_markers,
"fallback_reason": status.fallback_reason,
}))?
serde_json::to_string_pretty(&sandbox_json_value(&status))?
),
}
Ok(())
}
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"in_container": status.in_container,
"requested_namespace": status.requested.namespace_restrictions,
"active_namespace": status.namespace_active,
"requested_network": status.requested.network_isolation,
"active_network": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts,
"markers": status.container_markers,
"fallback_reason": status.fallback_reason,
})
}
fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
@@ -4436,15 +4461,19 @@ fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Er
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "init",
"message": message,
}))?
serde_json::to_string_pretty(&init_json_value(&message))?
),
}
Ok(())
}
fn init_json_value(message: &str) -> serde_json::Value {
json!({
"kind": "init",
"message": message,
})
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),

View File

@@ -259,6 +259,104 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-inventory-json");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
)
.expect("session should write");
let mcp = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/mcp",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
assert!(mcp["servers"].is_array());
let skills = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/skills",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
}
#[test]
fn resumed_version_and_init_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-version-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
)
.expect("session should write");
let version = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/version",
],
);
assert_eq!(version["kind"], "version");
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
let init = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/init",
],
);
assert_eq!(init["kind"], "init");
assert!(root.join("CLAUDE.md").exists());
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}

View File

@@ -275,6 +275,49 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_sandbox_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-sandbox-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.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"),
"/sandbox",
],
);
// 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 sandbox output should be json");
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["enabled"].is_boolean());
assert!(parsed["active"].is_boolean());
assert!(parsed["supported"].is_boolean());
assert!(parsed["filesystem_mode"].as_str().is_some());
assert!(parsed["allowed_mounts"].is_array());
assert!(parsed["markers"].is_array());
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
run_claw_with_env(current_dir, args, &[])
}