Compare commits
10
Commits
1f1d437f08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a0973f6c | ||
|
|
fe4da2aa65 | ||
|
|
53d6909b9b | ||
|
|
ceaf9cbc23 | ||
|
|
ee92f131b0 | ||
|
|
df0908b10e | ||
|
|
22e3f8c5e3 | ||
|
|
d94d792a48 | ||
|
|
2bab4080d6 | ||
|
|
f7321ca05d |
@@ -307,6 +307,11 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
|
||||
18. **Context-window preflight gap** — **done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic.
|
||||
19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
|
||||
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
|
||||
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
|
||||
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 local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
|
||||
**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
|
||||
|
||||
@@ -2,6 +2,11 @@ use std::env::VarError;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::time::Duration;
|
||||
|
||||
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
|
||||
"something went wrong while processing your request",
|
||||
"please try again, or use /new to start a fresh session",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
MissingCredentials {
|
||||
@@ -25,6 +30,7 @@ pub enum ApiError {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
},
|
||||
@@ -65,6 +71,66 @@ impl ApiError {
|
||||
| Self::BackoffOverflow { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn request_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api { request_id, .. } => request_id.as_deref(),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json(_)
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn safe_failure_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::ContextWindowExceeded { .. } => "context_window",
|
||||
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api { .. } => "provider_error",
|
||||
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||
"provider_transport"
|
||||
}
|
||||
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_generic_fatal_wrapper(&self) -> bool {
|
||||
match self {
|
||||
Self::Api { message, body, .. } => {
|
||||
message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_generic_fatal_wrapper)
|
||||
|| looks_like_generic_fatal_wrapper(body)
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json(_)
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ApiError {
|
||||
@@ -102,14 +168,24 @@ impl Display for ApiError {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} => match (error_type, message) {
|
||||
(Some(error_type), Some(message)) => {
|
||||
write!(f, "api returned {status} ({error_type}): {message}")
|
||||
} => {
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {message}")
|
||||
} else {
|
||||
write!(f, "api returned {status}")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {body}")
|
||||
}
|
||||
_ => write!(f, "api returned {status}: {body}"),
|
||||
},
|
||||
}
|
||||
Self::RetriesExhausted {
|
||||
attempts,
|
||||
last_error,
|
||||
@@ -151,3 +227,57 @@ impl From<VarError> for ApiError {
|
||||
Self::InvalidApiKeyEnv(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
GENERIC_FATAL_WRAPPER_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiError;
|
||||
|
||||
#[test]
|
||||
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
||||
let error = ApiError::Api {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
|
||||
.to_string(),
|
||||
),
|
||||
request_id: Some("req_jobdori_123".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
assert_eq!(error.safe_failure_class(), "provider_internal");
|
||||
assert_eq!(error.request_id(), Some("req_jobdori_123"));
|
||||
assert!(error.to_string().contains("[trace req_jobdori_123]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
|
||||
let error = ApiError::RetriesExhausted {
|
||||
attempts: 3,
|
||||
last_error: Box::new(ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_GATEWAY,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
|
||||
.to_string(),
|
||||
),
|
||||
request_id: Some("req_nested_456".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
|
||||
assert_eq!(error.request_id(), Some("req_nested_456"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +808,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
message: parsed_error
|
||||
.as_ref()
|
||||
.map(|error| error.error.message.clone()),
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
})
|
||||
|
||||
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
message: parsed_error
|
||||
.as_ref()
|
||||
.and_then(|error| error.error.message.clone()),
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
})
|
||||
|
||||
@@ -2187,6 +2187,27 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
|
||||
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_json(None),
|
||||
_ => render_agents_usage_json(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_json(cwd, &agents))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
|
||||
Some(args) => Ok(render_agents_usage_json(Some(args))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_mcp_slash_command(
|
||||
args: Option<&str>,
|
||||
cwd: &Path,
|
||||
@@ -3039,6 +3060,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
|
||||
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
let active = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.shadowed_by.is_none())
|
||||
.count();
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "list",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"count": agents.len(),
|
||||
"summary": {
|
||||
"total": agents.len(),
|
||||
"active": active,
|
||||
"shadowed": agents.len().saturating_sub(active),
|
||||
},
|
||||
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_detail(agent: &AgentSummary) -> String {
|
||||
let mut parts = vec![agent.name.clone()];
|
||||
if let Some(description) = &agent.description {
|
||||
@@ -3327,6 +3367,19 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "help",
|
||||
"usage": {
|
||||
"slash_command": "/agents [list|help]",
|
||||
"direct_cli": "claw agents [list|help]",
|
||||
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"Skills".to_string(),
|
||||
@@ -3478,6 +3531,18 @@ fn definition_source_json(source: DefinitionSource) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_summary_json(agent: &AgentSummary) -> Value {
|
||||
json!({
|
||||
"name": &agent.name,
|
||||
"description": &agent.description,
|
||||
"model": &agent.model,
|
||||
"reasoning_effort": &agent.reasoning_effort,
|
||||
"source": definition_source_json(agent.source),
|
||||
"active": agent.shadowed_by.is_none(),
|
||||
"shadowed_by": agent.shadowed_by.map(definition_source_json),
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
|
||||
match origin {
|
||||
SkillOrigin::SkillsDir => "skills_dir",
|
||||
@@ -3686,8 +3751,9 @@ pub fn handle_slash_command(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||
handle_agents_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command_json, handle_slash_command, load_agents_from_roots,
|
||||
load_skills_from_roots, render_agents_report, render_agents_report_json,
|
||||
render_mcp_report_json_for, render_plugins_report, render_skills_report,
|
||||
render_slash_command_help, render_slash_command_help_detail,
|
||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
||||
@@ -4363,6 +4429,72 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_agents_reports_as_json() {
|
||||
let workspace = temp_dir("agents-json-workspace");
|
||||
let project_agents = workspace.join(".codex").join("agents");
|
||||
let user_home = temp_dir("agents-json-home");
|
||||
let user_agents = user_home.join(".codex").join("agents");
|
||||
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"planner",
|
||||
"Project planner",
|
||||
"gpt-5.4",
|
||||
"medium",
|
||||
);
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"verifier",
|
||||
"Verification agent",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
write_agent(
|
||||
&user_agents,
|
||||
"planner",
|
||||
"User planner",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
|
||||
let roots = vec![
|
||||
(DefinitionSource::ProjectCodex, project_agents),
|
||||
(DefinitionSource::UserCodex, user_agents),
|
||||
];
|
||||
let report = render_agents_report_json(
|
||||
&workspace,
|
||||
&load_agents_from_roots(&roots).expect("agent roots should load"),
|
||||
);
|
||||
|
||||
assert_eq!(report["kind"], "agents");
|
||||
assert_eq!(report["action"], "list");
|
||||
assert_eq!(report["working_directory"], workspace.display().to_string());
|
||||
assert_eq!(report["count"], 3);
|
||||
assert_eq!(report["summary"]["active"], 2);
|
||||
assert_eq!(report["summary"]["shadowed"], 1);
|
||||
assert_eq!(report["agents"][0]["name"], "planner");
|
||||
assert_eq!(report["agents"][0]["model"], "gpt-5.4");
|
||||
assert_eq!(report["agents"][0]["active"], true);
|
||||
assert_eq!(report["agents"][1]["name"], "verifier");
|
||||
assert_eq!(report["agents"][2]["name"], "planner");
|
||||
assert_eq!(report["agents"][2]["active"], false);
|
||||
assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||
|
||||
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
|
||||
assert_eq!(help["kind"], "agents");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
|
||||
|
||||
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
|
||||
.expect("agents usage");
|
||||
assert_eq!(unexpected["action"], "help");
|
||||
assert_eq!(unexpected["unexpected"], "show planner");
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_skills_from_project_and_user_roots() {
|
||||
let workspace = temp_dir("skills-workspace");
|
||||
|
||||
@@ -31,10 +31,10 @@ use api::{
|
||||
};
|
||||
|
||||
use commands::{
|
||||
handle_agents_slash_command, handle_mcp_slash_command, handle_mcp_slash_command_json,
|
||||
handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json,
|
||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||
validate_slash_command_input, SlashCommand,
|
||||
handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command,
|
||||
handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command,
|
||||
handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, SlashCommand,
|
||||
};
|
||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||
use init::initialize_repo;
|
||||
@@ -51,7 +51,7 @@ use runtime::{
|
||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
||||
|
||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||
@@ -870,6 +870,7 @@ struct DiagnosticCheck {
|
||||
level: DiagnosticLevel,
|
||||
summary: String,
|
||||
details: Vec<String>,
|
||||
data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl DiagnosticCheck {
|
||||
@@ -879,6 +880,7 @@ impl DiagnosticCheck {
|
||||
level,
|
||||
summary: summary.into(),
|
||||
details: Vec::new(),
|
||||
data: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,6 +888,37 @@ impl DiagnosticCheck {
|
||||
self.details = details;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_data(mut self, data: Map<String, Value>) -> Self {
|
||||
self.data = data;
|
||||
self
|
||||
}
|
||||
|
||||
fn json_value(&self) -> Value {
|
||||
let mut value = Map::from_iter([
|
||||
(
|
||||
"name".to_string(),
|
||||
Value::String(self.name.to_ascii_lowercase()),
|
||||
),
|
||||
(
|
||||
"status".to_string(),
|
||||
Value::String(self.level.label().to_string()),
|
||||
),
|
||||
("summary".to_string(), Value::String(self.summary.clone())),
|
||||
(
|
||||
"details".to_string(),
|
||||
Value::Array(
|
||||
self.details
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
value.extend(self.data.clone());
|
||||
Value::Object(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -894,26 +927,29 @@ struct DoctorReport {
|
||||
}
|
||||
|
||||
impl DoctorReport {
|
||||
fn counts(&self) -> (usize, usize, usize) {
|
||||
(
|
||||
self.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Ok)
|
||||
.count(),
|
||||
self.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Warn)
|
||||
.count(),
|
||||
self.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Fail)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_failures(&self) -> bool {
|
||||
self.checks.iter().any(|check| check.level.is_failure())
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
let ok_count = self
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Ok)
|
||||
.count();
|
||||
let warn_count = self
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Warn)
|
||||
.count();
|
||||
let fail_count = self
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|check| check.level == DiagnosticLevel::Fail)
|
||||
.count();
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
let mut lines = vec![
|
||||
"Doctor".to_string(),
|
||||
format!(
|
||||
@@ -923,6 +959,28 @@ impl DoctorReport {
|
||||
lines.extend(self.checks.iter().map(render_diagnostic_check));
|
||||
lines.join("\n\n")
|
||||
}
|
||||
|
||||
fn json_value(&self) -> Value {
|
||||
let report = self.render();
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
json!({
|
||||
"kind": "doctor",
|
||||
"message": report,
|
||||
"report": report,
|
||||
"has_failures": self.has_failures(),
|
||||
"summary": {
|
||||
"total": self.checks.len(),
|
||||
"ok": ok_count,
|
||||
"warnings": warn_count,
|
||||
"failures": fail_count,
|
||||
},
|
||||
"checks": self
|
||||
.checks
|
||||
.iter()
|
||||
.map(DiagnosticCheck::json_value)
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
|
||||
@@ -980,15 +1038,9 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "doctor",
|
||||
"message": message,
|
||||
"report": message,
|
||||
"has_failures": report.has_failures(),
|
||||
}))?
|
||||
),
|
||||
CliOutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
|
||||
}
|
||||
}
|
||||
if report.has_failures() {
|
||||
return Err("doctor found failing checks".into());
|
||||
@@ -996,6 +1048,7 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn check_auth_health() -> DiagnosticCheck {
|
||||
let api_key_present = env::var("ANTHROPIC_API_KEY")
|
||||
.ok()
|
||||
@@ -1060,6 +1113,21 @@ fn check_auth_health() -> DiagnosticCheck {
|
||||
},
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("saved_oauth_present".to_string(), json!(true)),
|
||||
("saved_oauth_expired".to_string(), json!(expired)),
|
||||
(
|
||||
"saved_oauth_expires_at".to_string(),
|
||||
json!(token_set.expires_at),
|
||||
),
|
||||
(
|
||||
"refresh_token_present".to_string(),
|
||||
json!(token_set.refresh_token.is_some()),
|
||||
),
|
||||
("scopes".to_string(), json!(token_set.scopes)),
|
||||
]))
|
||||
}
|
||||
Ok(None) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
@@ -1082,12 +1150,31 @@ fn check_auth_health() -> DiagnosticCheck {
|
||||
} else {
|
||||
"absent"
|
||||
}
|
||||
)]),
|
||||
)])
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("saved_oauth_present".to_string(), json!(false)),
|
||||
("saved_oauth_expired".to_string(), json!(false)),
|
||||
("saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("refresh_token_present".to_string(), json!(false)),
|
||||
("scopes".to_string(), json!(Vec::<String>::new())),
|
||||
])),
|
||||
Err(error) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
DiagnosticLevel::Fail,
|
||||
format!("failed to inspect saved credentials: {error}"),
|
||||
),
|
||||
)
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("saved_oauth_present".to_string(), Value::Null),
|
||||
("saved_oauth_expired".to_string(), Value::Null),
|
||||
("saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("refresh_token_present".to_string(), Value::Null),
|
||||
("scopes".to_string(), Value::Null),
|
||||
("saved_oauth_error".to_string(), json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,7 +1208,7 @@ fn check_config_health(
|
||||
} else {
|
||||
details.extend(
|
||||
discovered_paths
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|path| format!("Discovered file {path}")),
|
||||
);
|
||||
}
|
||||
@@ -1139,6 +1226,22 @@ fn check_config_health(
|
||||
},
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(), json!(discovered_paths)),
|
||||
(
|
||||
"discovered_files_count".to_string(),
|
||||
json!(discovered_count),
|
||||
),
|
||||
(
|
||||
"loaded_config_files".to_string(),
|
||||
json!(loaded_entries.len()),
|
||||
),
|
||||
("resolved_model".to_string(), json!(runtime_config.model())),
|
||||
(
|
||||
"mcp_servers".to_string(),
|
||||
json!(runtime_config.mcp().servers().len()),
|
||||
),
|
||||
]))
|
||||
}
|
||||
Err(error) => DiagnosticCheck::new(
|
||||
"Config",
|
||||
@@ -1149,10 +1252,21 @@ fn check_config_health(
|
||||
vec!["Discovered files <none>".to_string()]
|
||||
} else {
|
||||
discovered_paths
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|path| format!("Discovered file {path}"))
|
||||
.collect()
|
||||
}),
|
||||
})
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(), json!(discovered_paths)),
|
||||
(
|
||||
"discovered_files_count".to_string(),
|
||||
json!(discovered_count),
|
||||
),
|
||||
("loaded_config_files".to_string(), json!(0)),
|
||||
("resolved_model".to_string(), Value::Null),
|
||||
("mcp_servers".to_string(), Value::Null),
|
||||
("load_error".to_string(), json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,6 +1308,38 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
context.memory_file_count, context.loaded_config_files, context.discovered_config_files
|
||||
),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("cwd".to_string(), json!(context.cwd.display().to_string())),
|
||||
(
|
||||
"project_root".to_string(),
|
||||
json!(context
|
||||
.project_root
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())),
|
||||
),
|
||||
("in_git_repo".to_string(), json!(in_repo)),
|
||||
("git_branch".to_string(), json!(context.git_branch)),
|
||||
(
|
||||
"git_state".to_string(),
|
||||
json!(context.git_summary.headline()),
|
||||
),
|
||||
(
|
||||
"changed_files".to_string(),
|
||||
json!(context.git_summary.changed_files),
|
||||
),
|
||||
(
|
||||
"memory_file_count".to_string(),
|
||||
json!(context.memory_file_count),
|
||||
),
|
||||
(
|
||||
"loaded_config_files".to_string(),
|
||||
json!(context.loaded_config_files),
|
||||
),
|
||||
(
|
||||
"discovered_config_files".to_string(),
|
||||
json!(context.discovered_config_files),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
||||
@@ -1224,9 +1370,43 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
||||
},
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("enabled".to_string(), json!(status.enabled)),
|
||||
("active".to_string(), json!(status.active)),
|
||||
("supported".to_string(), json!(status.supported)),
|
||||
(
|
||||
"namespace_supported".to_string(),
|
||||
json!(status.namespace_supported),
|
||||
),
|
||||
(
|
||||
"namespace_active".to_string(),
|
||||
json!(status.namespace_active),
|
||||
),
|
||||
(
|
||||
"network_supported".to_string(),
|
||||
json!(status.network_supported),
|
||||
),
|
||||
("network_active".to_string(), json!(status.network_active)),
|
||||
(
|
||||
"filesystem_mode".to_string(),
|
||||
json!(status.filesystem_mode.as_str()),
|
||||
),
|
||||
(
|
||||
"filesystem_active".to_string(),
|
||||
json!(status.filesystem_active),
|
||||
),
|
||||
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
|
||||
("in_container".to_string(), json!(status.in_container)),
|
||||
(
|
||||
"container_markers".to_string(),
|
||||
json!(status.container_markers),
|
||||
),
|
||||
("fallback_reason".to_string(), json!(status.fallback_reason)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
|
||||
let default_model = config.and_then(runtime::RuntimeConfig::model);
|
||||
let mut details = vec![
|
||||
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
|
||||
format!("Working dir {}", cwd.display()),
|
||||
@@ -1234,7 +1414,7 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
||||
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
|
||||
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
|
||||
];
|
||||
if let Some(model) = config.and_then(runtime::RuntimeConfig::model) {
|
||||
if let Some(model) = default_model {
|
||||
details.push(format!("Default model {model}"));
|
||||
}
|
||||
DiagnosticCheck::new(
|
||||
@@ -1243,6 +1423,15 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
||||
"captured local runtime metadata",
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("os".to_string(), json!(env::consts::OS)),
|
||||
("arch".to_string(), json!(env::consts::ARCH)),
|
||||
("working_dir".to_string(), json!(cwd.display().to_string())),
|
||||
("version".to_string(), json!(VERSION)),
|
||||
("build_target".to_string(), json!(BUILD_TARGET)),
|
||||
("git_sha".to_string(), json!(GIT_SHA)),
|
||||
("default_model".to_string(), json!(default_model)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
|
||||
@@ -1499,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()
|
||||
@@ -1563,38 +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 usage = tracker.cumulative_usage();
|
||||
let context = status_context(Some(&resolved_path)).expect("status context");
|
||||
let value = json!({
|
||||
"kind": "status",
|
||||
"messages": session.messages.len(),
|
||||
"turns": tracker.turns(),
|
||||
"latest_total": tracker.current_turn_usage().total_tokens(),
|
||||
"cumulative_input": usage.input_tokens,
|
||||
"cumulative_output": usage.output_tokens,
|
||||
"cumulative_total": usage.total_tokens(),
|
||||
"workspace": {
|
||||
"cwd": context.cwd,
|
||||
"project_root": context.project_root,
|
||||
"git_branch": context.git_branch,
|
||||
"git_state": context.git_summary.headline(),
|
||||
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||
}
|
||||
});
|
||||
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) => {
|
||||
@@ -1609,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)]
|
||||
@@ -1943,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(
|
||||
@@ -1959,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 } => {
|
||||
@@ -1968,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)?;
|
||||
@@ -1983,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(
|
||||
@@ -2000,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,
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -2008,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 => {
|
||||
@@ -2021,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()?;
|
||||
@@ -2038,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)?;
|
||||
@@ -2068,6 +2269,7 @@ fn run_resume_command(
|
||||
export_path.display(),
|
||||
session.messages.len(),
|
||||
)),
|
||||
json: None,
|
||||
})
|
||||
}
|
||||
SlashCommand::Agents { args } => {
|
||||
@@ -2075,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 } => {
|
||||
@@ -2082,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 { .. }
|
||||
@@ -3281,16 +3486,11 @@ impl LiveCli {
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let message = handle_agents_slash_command(args, &cwd)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "agents",
|
||||
"message": message,
|
||||
"args": args,
|
||||
}))?
|
||||
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
@@ -3845,54 +4045,68 @@ fn print_status_snapshot(
|
||||
),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "status",
|
||||
"model": model,
|
||||
"permission_mode": permission_mode.as_str(),
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
"latest_total": usage.latest.total_tokens(),
|
||||
"cumulative_input": usage.cumulative.input_tokens,
|
||||
"cumulative_output": usage.cumulative.output_tokens,
|
||||
"cumulative_total": usage.cumulative.total_tokens(),
|
||||
"estimated_tokens": usage.estimated_tokens,
|
||||
},
|
||||
"workspace": {
|
||||
"cwd": context.cwd,
|
||||
"project_root": context.project_root,
|
||||
"git_branch": context.git_branch,
|
||||
"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": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": context.sandbox_status.enabled,
|
||||
"active": context.sandbox_status.active,
|
||||
"supported": context.sandbox_status.supported,
|
||||
"in_container": context.sandbox_status.in_container,
|
||||
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
|
||||
"active_namespace": context.sandbox_status.namespace_active,
|
||||
"requested_network": context.sandbox_status.requested.network_isolation,
|
||||
"active_network": context.sandbox_status.network_active,
|
||||
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
|
||||
"filesystem_active": context.sandbox_status.filesystem_active,
|
||||
"allowed_mounts": context.sandbox_status.allowed_mounts,
|
||||
"markers": context.sandbox_status.container_markers,
|
||||
"fallback_reason": context.sandbox_status.fallback_reason,
|
||||
}
|
||||
}))?
|
||||
serde_json::to_string_pretty(&status_json_value(
|
||||
model,
|
||||
usage,
|
||||
permission_mode.as_str(),
|
||||
&context,
|
||||
))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_json_value(
|
||||
model: &str,
|
||||
usage: StatusUsage,
|
||||
permission_mode: &str,
|
||||
context: &StatusContext,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"kind": "status",
|
||||
"model": model,
|
||||
"permission_mode": permission_mode,
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
"latest_total": usage.latest.total_tokens(),
|
||||
"cumulative_input": usage.cumulative.input_tokens,
|
||||
"cumulative_output": usage.cumulative.output_tokens,
|
||||
"cumulative_total": usage.cumulative.total_tokens(),
|
||||
"estimated_tokens": usage.estimated_tokens,
|
||||
},
|
||||
"workspace": {
|
||||
"cwd": context.cwd,
|
||||
"project_root": context.project_root,
|
||||
"git_branch": context.git_branch,
|
||||
"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": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": context.sandbox_status.enabled,
|
||||
"active": context.sandbox_status.active,
|
||||
"supported": context.sandbox_status.supported,
|
||||
"in_container": context.sandbox_status.in_container,
|
||||
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
|
||||
"active_namespace": context.sandbox_status.namespace_active,
|
||||
"requested_network": context.sandbox_status.requested.network_isolation,
|
||||
"active_network": context.sandbox_status.network_active,
|
||||
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
|
||||
"filesystem_active": context.sandbox_status.filesystem_active,
|
||||
"allowed_mounts": context.sandbox_status.allowed_mounts,
|
||||
"markers": context.sandbox_status.container_markers,
|
||||
"fallback_reason": context.sandbox_status.fallback_reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn status_context(
|
||||
session_path: Option<&Path>,
|
||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||
@@ -4066,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
|
||||
@@ -4243,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"),
|
||||
@@ -5230,6 +5452,7 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: AnthropicClient,
|
||||
session_id: String,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
@@ -5253,6 +5476,7 @@ impl AnthropicRuntimeClient {
|
||||
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id)),
|
||||
session_id: session_id.to_string(),
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
@@ -5292,11 +5516,13 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
};
|
||||
|
||||
self.runtime.block_on(async {
|
||||
let mut stream = self
|
||||
.client
|
||||
.stream_message(&message_request)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
let mut stream =
|
||||
self.client
|
||||
.stream_message(&message_request)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut sink = io::sink();
|
||||
let out: &mut dyn Write = if self.emit_output {
|
||||
@@ -5310,11 +5536,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
let mut pending_tool: Option<(String, String, String)> = None;
|
||||
let mut saw_stop = false;
|
||||
|
||||
while let Some(event) = stream
|
||||
.next_event()
|
||||
.await
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?
|
||||
{
|
||||
while let Some(event) = stream.next_event().await.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})? {
|
||||
match event {
|
||||
ApiStreamEvent::MessageStart(start) => {
|
||||
for block in start.message.content {
|
||||
@@ -5409,7 +5633,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
..message_request.clone()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?;
|
||||
let mut events = response_to_events(response, out)?;
|
||||
push_prompt_cache_record(&self.client, &mut events);
|
||||
Ok(events)
|
||||
@@ -5417,6 +5643,23 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
|
||||
if error.is_generic_fatal_wrapper() {
|
||||
let mut qualifiers = vec![format!("session {session_id}")];
|
||||
if let Some(request_id) = error.request_id() {
|
||||
qualifiers.push(format!("trace {request_id}"));
|
||||
}
|
||||
format!(
|
||||
"{} ({}): {}",
|
||||
error.safe_failure_class(),
|
||||
qualifiers.join(", "),
|
||||
error
|
||||
)
|
||||
} else {
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
summary
|
||||
.assistant_messages
|
||||
@@ -6415,18 +6658,19 @@ mod tests {
|
||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
||||
format_ultraplan_report, format_unknown_slash_command,
|
||||
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
|
||||
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,
|
||||
format_unknown_slash_command_message, format_user_visible_api_error,
|
||||
normalize_permission_mode, parse_args, 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, LocalHelpTopic,
|
||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
||||
};
|
||||
@@ -6466,12 +6710,59 @@ mod tests {
|
||||
.expect("plugin tool registry should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_provider_wrapper_surfaces_failure_class_session_and_trace() {
|
||||
let error = ApiError::Api {
|
||||
status: "500".parse().expect("status"),
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
|
||||
.to_string(),
|
||||
),
|
||||
request_id: Some("req_jobdori_789".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-22", &error);
|
||||
assert!(rendered.contains("provider_internal"));
|
||||
assert!(rendered.contains("session session-issue-22"));
|
||||
assert!(rendered.contains("trace req_jobdori_789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper() {
|
||||
let error = ApiError::RetriesExhausted {
|
||||
attempts: 3,
|
||||
last_error: Box::new(ApiError::Api {
|
||||
status: "502".parse().expect("status"),
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
|
||||
.to_string(),
|
||||
),
|
||||
request_id: Some("req_jobdori_790".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
}),
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-22", &error);
|
||||
assert!(rendered.contains("provider_retry_exhausted"), "{rendered}");
|
||||
assert!(rendered.contains("session session-issue-22"));
|
||||
assert!(rendered.contains("trace req_jobdori_790"));
|
||||
}
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}"))
|
||||
}
|
||||
|
||||
fn git(args: &[&str], cwd: &Path) {
|
||||
|
||||
@@ -50,8 +50,34 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("inventory-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let agents = assert_json_command(&root, &["--output-format", "json", "agents"]);
|
||||
let isolated_home = root.join("home");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&isolated_home).expect("isolated home should exist");
|
||||
|
||||
let agents = assert_json_command_with_env(
|
||||
&root,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", isolated_home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(agents["kind"], "agents");
|
||||
assert_eq!(agents["action"], "list");
|
||||
assert_eq!(agents["count"], 0);
|
||||
assert_eq!(agents["summary"]["active"], 0);
|
||||
assert!(agents["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert_eq!(skills["action"], "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_command_emits_structured_agent_entries_when_requested() {
|
||||
let root = unique_temp_dir("agents-json-populated");
|
||||
let workspace = root.join("workspace");
|
||||
let project_agents = workspace.join(".codex").join("agents");
|
||||
let home = root.join("home");
|
||||
let user_agents = home.join(".codex").join("agents");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"planner",
|
||||
"Project planner",
|
||||
"gpt-5.4",
|
||||
"medium",
|
||||
);
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"verifier",
|
||||
"Verification agent",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
write_agent(
|
||||
&user_agents,
|
||||
"planner",
|
||||
"User planner",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
|
||||
let parsed = assert_json_command_with_env(
|
||||
&workspace,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(parsed["kind"], "agents");
|
||||
assert_eq!(parsed["action"], "list");
|
||||
assert_eq!(parsed["count"], 3);
|
||||
assert_eq!(parsed["summary"]["active"], 2);
|
||||
assert_eq!(parsed["summary"]["shadowed"], 1);
|
||||
assert_eq!(parsed["agents"][0]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
|
||||
assert_eq!(parsed["agents"][0]["active"], true);
|
||||
assert_eq!(parsed["agents"][1]["name"], "verifier");
|
||||
assert_eq!(parsed["agents"][2]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][2]["active"], false);
|
||||
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||
@@ -112,6 +200,41 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||
assert_eq!(doctor["kind"], "doctor");
|
||||
assert!(doctor["message"].is_string());
|
||||
let summary = doctor["summary"].as_object().expect("doctor summary");
|
||||
assert!(summary["ok"].as_u64().is_some());
|
||||
assert!(summary["warnings"].as_u64().is_some());
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 5);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
assert!(check["status"].as_str().is_some());
|
||||
assert!(check["summary"].as_str().is_some());
|
||||
assert!(check["details"].is_array());
|
||||
check["name"].as_str().expect("doctor check name")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
check_names,
|
||||
vec!["auth", "config", "workspace", "sandbox", "system"]
|
||||
);
|
||||
|
||||
let workspace = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "workspace")
|
||||
.expect("workspace check");
|
||||
assert!(workspace["cwd"].as_str().is_some());
|
||||
assert!(workspace["in_git_repo"].is_boolean());
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "sandbox")
|
||||
.expect("sandbox check");
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
assert!(sandbox["enabled"].is_boolean());
|
||||
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
@@ -130,7 +253,108 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
],
|
||||
);
|
||||
assert_eq!(resumed["kind"], "status");
|
||||
assert_eq!(resumed["messages"], 1);
|
||||
assert_eq!(resumed["model"], "restored-session");
|
||||
assert_eq!(resumed["usage"]["messages"], 1);
|
||||
assert!(resumed["workspace"]["cwd"].as_str().is_some());
|
||||
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 {
|
||||
@@ -180,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
upstream
|
||||
}
|
||||
|
||||
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
||||
fs::create_dir_all(root).expect("agent root should exist");
|
||||
fs::write(
|
||||
root.join(format!("{name}.toml")),
|
||||
format!(
|
||||
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
|
||||
),
|
||||
)
|
||||
.expect("agent fixture should write");
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -261,11 +261,61 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
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["model"], "restored-session");
|
||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||
assert_eq!(parsed["usage"]["messages"], 1);
|
||||
assert!(parsed["usage"]["turns"].is_number());
|
||||
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
||||
assert_eq!(
|
||||
parsed["workspace"]["session"],
|
||||
session_path.to_str().expect("utf8 path")
|
||||
);
|
||||
assert!(parsed["workspace"]["changed_files"].is_number());
|
||||
assert_eq!(parsed["workspace"]["loaded_config_files"].as_u64(), Some(0));
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user