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

@@ -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"),