Compare commits
10
Commits
1f1d437f08
...
84a0973f6c
| 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.
|
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.
|
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.
|
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**
|
**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
|
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
|
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::fmt::{Display, Formatter};
|
||||||
use std::time::Duration;
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
MissingCredentials {
|
MissingCredentials {
|
||||||
@@ -25,6 +30,7 @@ pub enum ApiError {
|
|||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
error_type: Option<String>,
|
error_type: Option<String>,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
request_id: Option<String>,
|
||||||
body: String,
|
body: String,
|
||||||
retryable: bool,
|
retryable: bool,
|
||||||
},
|
},
|
||||||
@@ -65,6 +71,66 @@ impl ApiError {
|
|||||||
| Self::BackoffOverflow { .. } => false,
|
| 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 {
|
impl Display for ApiError {
|
||||||
@@ -102,14 +168,24 @@ impl Display for ApiError {
|
|||||||
status,
|
status,
|
||||||
error_type,
|
error_type,
|
||||||
message,
|
message,
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
..
|
..
|
||||||
} => match (error_type, message) {
|
} => {
|
||||||
(Some(error_type), Some(message)) => {
|
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||||
write!(f, "api returned {status} ({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 {
|
Self::RetriesExhausted {
|
||||||
attempts,
|
attempts,
|
||||||
last_error,
|
last_error,
|
||||||
@@ -151,3 +227,57 @@ impl From<VarError> for ApiError {
|
|||||||
Self::InvalidApiKeyEnv(value)
|
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);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||||
let retryable = is_retryable_status(status);
|
let retryable = is_retryable_status(status);
|
||||||
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
message: parsed_error
|
message: parsed_error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|error| error.error.message.clone()),
|
.map(|error| error.error.message.clone()),
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
retryable,
|
retryable,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||||
let retryable = is_retryable_status(status);
|
let retryable = is_retryable_status(status);
|
||||||
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
message: parsed_error
|
message: parsed_error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|error| error.error.message.clone()),
|
.and_then(|error| error.error.message.clone()),
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
retryable,
|
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(
|
pub fn handle_mcp_slash_command(
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
@@ -3039,6 +3060,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_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 {
|
fn agent_detail(agent: &AgentSummary) -> String {
|
||||||
let mut parts = vec![agent.name.clone()];
|
let mut parts = vec![agent.name.clone()];
|
||||||
if let Some(description) = &agent.description {
|
if let Some(description) = &agent.description {
|
||||||
@@ -3327,6 +3367,19 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
|||||||
lines.join("\n")
|
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 {
|
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Skills".to_string(),
|
"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 {
|
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
|
||||||
match origin {
|
match origin {
|
||||||
SkillOrigin::SkillsDir => "skills_dir",
|
SkillOrigin::SkillsDir => "skills_dir",
|
||||||
@@ -3686,8 +3751,9 @@ pub fn handle_slash_command(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
handle_agents_slash_command_json, handle_plugins_slash_command,
|
||||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
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_mcp_report_json_for, render_plugins_report, render_skills_report,
|
||||||
render_slash_command_help, render_slash_command_help_detail,
|
render_slash_command_help, render_slash_command_help_detail,
|
||||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
||||||
@@ -4363,6 +4429,72 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(user_home);
|
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]
|
#[test]
|
||||||
fn lists_skills_from_project_and_user_roots() {
|
fn lists_skills_from_project_and_user_roots() {
|
||||||
let workspace = temp_dir("skills-workspace");
|
let workspace = temp_dir("skills-workspace");
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ use api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
handle_agents_slash_command, handle_mcp_slash_command, handle_mcp_slash_command_json,
|
handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command,
|
||||||
handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json,
|
handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands,
|
||||||
validate_slash_command_input, SlashCommand,
|
slash_command_specs, validate_slash_command_input, SlashCommand,
|
||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
@@ -51,7 +51,7 @@ use runtime::{
|
|||||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::{json, Map, Value};
|
||||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
||||||
|
|
||||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -870,6 +870,7 @@ struct DiagnosticCheck {
|
|||||||
level: DiagnosticLevel,
|
level: DiagnosticLevel,
|
||||||
summary: String,
|
summary: String,
|
||||||
details: Vec<String>,
|
details: Vec<String>,
|
||||||
|
data: Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiagnosticCheck {
|
impl DiagnosticCheck {
|
||||||
@@ -879,6 +880,7 @@ impl DiagnosticCheck {
|
|||||||
level,
|
level,
|
||||||
summary: summary.into(),
|
summary: summary.into(),
|
||||||
details: Vec::new(),
|
details: Vec::new(),
|
||||||
|
data: Map::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,6 +888,37 @@ impl DiagnosticCheck {
|
|||||||
self.details = details;
|
self.details = details;
|
||||||
self
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -894,26 +927,29 @@ struct DoctorReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn has_failures(&self) -> bool {
|
||||||
self.checks.iter().any(|check| check.level.is_failure())
|
self.checks.iter().any(|check| check.level.is_failure())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self) -> String {
|
fn render(&self) -> String {
|
||||||
let ok_count = self
|
let (ok_count, warn_count, fail_count) = self.counts();
|
||||||
.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 mut lines = vec![
|
let mut lines = vec![
|
||||||
"Doctor".to_string(),
|
"Doctor".to_string(),
|
||||||
format!(
|
format!(
|
||||||
@@ -923,6 +959,28 @@ impl DoctorReport {
|
|||||||
lines.extend(self.checks.iter().map(render_diagnostic_check));
|
lines.extend(self.checks.iter().map(render_diagnostic_check));
|
||||||
lines.join("\n\n")
|
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 {
|
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();
|
let message = report.render();
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{message}"),
|
CliOutputFormat::Text => println!("{message}"),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => {
|
||||||
"{}",
|
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
|
||||||
serde_json::to_string_pretty(&json!({
|
}
|
||||||
"kind": "doctor",
|
|
||||||
"message": message,
|
|
||||||
"report": message,
|
|
||||||
"has_failures": report.has_failures(),
|
|
||||||
}))?
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if report.has_failures() {
|
if report.has_failures() {
|
||||||
return Err("doctor found failing checks".into());
|
return Err("doctor found failing checks".into());
|
||||||
@@ -996,6 +1048,7 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn check_auth_health() -> DiagnosticCheck {
|
fn check_auth_health() -> DiagnosticCheck {
|
||||||
let api_key_present = env::var("ANTHROPIC_API_KEY")
|
let api_key_present = env::var("ANTHROPIC_API_KEY")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1060,6 +1113,21 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_details(details)
|
.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(
|
Ok(None) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
@@ -1082,12 +1150,31 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
} else {
|
} else {
|
||||||
"absent"
|
"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(
|
Err(error) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
DiagnosticLevel::Fail,
|
DiagnosticLevel::Fail,
|
||||||
format!("failed to inspect saved credentials: {error}"),
|
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 {
|
} else {
|
||||||
details.extend(
|
details.extend(
|
||||||
discovered_paths
|
discovered_paths
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|path| format!("Discovered file {path}")),
|
.map(|path| format!("Discovered file {path}")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1139,6 +1226,22 @@ fn check_config_health(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_details(details)
|
.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(
|
Err(error) => DiagnosticCheck::new(
|
||||||
"Config",
|
"Config",
|
||||||
@@ -1149,10 +1252,21 @@ fn check_config_health(
|
|||||||
vec!["Discovered files <none>".to_string()]
|
vec!["Discovered files <none>".to_string()]
|
||||||
} else {
|
} else {
|
||||||
discovered_paths
|
discovered_paths
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|path| format!("Discovered file {path}"))
|
.map(|path| format!("Discovered file {path}"))
|
||||||
.collect()
|
.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
|
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 {
|
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
||||||
@@ -1224,9 +1370,43 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_details(details)
|
.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 {
|
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
|
||||||
|
let default_model = config.and_then(runtime::RuntimeConfig::model);
|
||||||
let mut details = vec![
|
let mut details = vec![
|
||||||
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
|
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
|
||||||
format!("Working dir {}", cwd.display()),
|
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!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
|
||||||
format!("Git SHA {}", GIT_SHA.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}"));
|
details.push(format!("Default model {model}"));
|
||||||
}
|
}
|
||||||
DiagnosticCheck::new(
|
DiagnosticCheck::new(
|
||||||
@@ -1243,6 +1423,15 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
|||||||
"captured local runtime metadata",
|
"captured local runtime metadata",
|
||||||
)
|
)
|
||||||
.with_details(details)
|
.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 {
|
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>> {
|
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let report = render_version_report();
|
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{report}"),
|
CliOutputFormat::Text => println!("{}", render_version_report()),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => {
|
||||||
"{}",
|
println!("{}", serde_json::to_string_pretty(&version_json_value())?);
|
||||||
serde_json::to_string_pretty(&json!({
|
}
|
||||||
"kind": "version",
|
|
||||||
"message": report,
|
|
||||||
"version": VERSION,
|
|
||||||
"git_sha": GIT_SHA,
|
|
||||||
"target": BUILD_TARGET,
|
|
||||||
}))?
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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) {
|
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||||
let resolved_path = if session_path.exists() {
|
let resolved_path = if session_path.exists() {
|
||||||
session_path.to_path_buf()
|
session_path.to_path_buf()
|
||||||
@@ -1563,38 +1754,21 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: next_session,
|
session: next_session,
|
||||||
message,
|
message,
|
||||||
|
json,
|
||||||
}) => {
|
}) => {
|
||||||
session = next_session;
|
session = next_session;
|
||||||
if let Some(message) = message {
|
if output_format == CliOutputFormat::Json {
|
||||||
if output_format == CliOutputFormat::Json
|
if let Some(value) = 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()),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
println!(
|
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}");
|
println!("{message}");
|
||||||
}
|
}
|
||||||
|
} else if let Some(message) = message {
|
||||||
|
println!("{message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -1609,6 +1783,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
|||||||
struct ResumeCommandOutcome {
|
struct ResumeCommandOutcome {
|
||||||
session: Session,
|
session: Session,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
json: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -1943,6 +2118,7 @@ fn run_resume_command(
|
|||||||
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_repl_help()),
|
message: Some(render_repl_help()),
|
||||||
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::Compact => {
|
SlashCommand::Compact => {
|
||||||
let result = runtime::compact_session(
|
let result = runtime::compact_session(
|
||||||
@@ -1959,6 +2135,7 @@ fn run_resume_command(
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: result.compacted_session,
|
session: result.compacted_session,
|
||||||
message: Some(format_compact_report(removed, kept, skipped)),
|
message: Some(format_compact_report(removed, kept, skipped)),
|
||||||
|
json: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Clear { confirm } => {
|
SlashCommand::Clear { confirm } => {
|
||||||
@@ -1968,6 +2145,7 @@ fn run_resume_command(
|
|||||||
message: Some(
|
message: Some(
|
||||||
"clear: confirmation required; rerun with /clear --confirm".to_string(),
|
"clear: confirmation required; rerun with /clear --confirm".to_string(),
|
||||||
),
|
),
|
||||||
|
json: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let backup_path = write_session_clear_backup(session, session_path)?;
|
let backup_path = write_session_clear_backup(session, session_path)?;
|
||||||
@@ -1983,11 +2161,13 @@ fn run_resume_command(
|
|||||||
backup_path.display(),
|
backup_path.display(),
|
||||||
session_path.display()
|
session_path.display()
|
||||||
)),
|
)),
|
||||||
|
json: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Status => {
|
SlashCommand::Status => {
|
||||||
let tracker = UsageTracker::from_session(session);
|
let tracker = UsageTracker::from_session(session);
|
||||||
let usage = tracker.cumulative_usage();
|
let usage = tracker.cumulative_usage();
|
||||||
|
let context = status_context(Some(session_path))?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_status_report(
|
message: Some(format_status_report(
|
||||||
@@ -2000,7 +2180,19 @@ fn run_resume_command(
|
|||||||
estimated_tokens: 0,
|
estimated_tokens: 0,
|
||||||
},
|
},
|
||||||
default_permission_mode().as_str(),
|
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 cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
|
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_sandbox_report(&resolve_sandbox_status(
|
message: Some(format_sandbox_report(&status)),
|
||||||
runtime_config.sandbox(),
|
json: Some(sandbox_json_value(&status)),
|
||||||
&cwd,
|
|
||||||
))),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Cost => {
|
SlashCommand::Cost => {
|
||||||
@@ -2021,11 +2212,13 @@ fn run_resume_command(
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_cost_report(usage)),
|
message: Some(format_cost_report(usage)),
|
||||||
|
json: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
|
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_config_report(section.as_deref())?),
|
message: Some(render_config_report(section.as_deref())?),
|
||||||
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::Mcp { action, target } => {
|
SlashCommand::Mcp { action, target } => {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
@@ -2038,25 +2231,33 @@ fn run_resume_command(
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
|
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 {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_memory_report()?),
|
message: Some(render_memory_report()?),
|
||||||
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::Init => Ok(ResumeCommandOutcome {
|
SlashCommand::Init => {
|
||||||
session: session.clone(),
|
let message = init_claude_md()?;
|
||||||
message: Some(init_claude_md()?),
|
Ok(ResumeCommandOutcome {
|
||||||
}),
|
session: session.clone(),
|
||||||
|
message: Some(message.clone()),
|
||||||
|
json: Some(init_json_value(&message)),
|
||||||
|
})
|
||||||
|
}
|
||||||
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_diff_report_for(
|
message: Some(render_diff_report_for(
|
||||||
session_path.parent().unwrap_or_else(|| Path::new(".")),
|
session_path.parent().unwrap_or_else(|| Path::new(".")),
|
||||||
)?),
|
)?),
|
||||||
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_version_report()),
|
message: Some(render_version_report()),
|
||||||
|
json: Some(version_json_value()),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Export { path } => {
|
SlashCommand::Export { path } => {
|
||||||
let export_path = resolve_export_path(path.as_deref(), session)?;
|
let export_path = resolve_export_path(path.as_deref(), session)?;
|
||||||
@@ -2068,6 +2269,7 @@ fn run_resume_command(
|
|||||||
export_path.display(),
|
export_path.display(),
|
||||||
session.messages.len(),
|
session.messages.len(),
|
||||||
)),
|
)),
|
||||||
|
json: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Agents { args } => {
|
SlashCommand::Agents { args } => {
|
||||||
@@ -2075,6 +2277,7 @@ fn run_resume_command(
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
|
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
|
||||||
|
json: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Skills { args } => {
|
SlashCommand::Skills { args } => {
|
||||||
@@ -2082,11 +2285,13 @@ fn run_resume_command(
|
|||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
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 {
|
SlashCommand::Doctor => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_doctor_report()?.render()),
|
message: Some(render_doctor_report()?.render()),
|
||||||
|
json: None,
|
||||||
}),
|
}),
|
||||||
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
||||||
SlashCommand::Bughunter { .. }
|
SlashCommand::Bughunter { .. }
|
||||||
@@ -3281,16 +3486,11 @@ impl LiveCli {
|
|||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let message = handle_agents_slash_command(args, &cwd)?;
|
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{message}"),
|
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
|
||||||
"kind": "agents",
|
|
||||||
"message": message,
|
|
||||||
"args": args,
|
|
||||||
}))?
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3845,54 +4045,68 @@ fn print_status_snapshot(
|
|||||||
),
|
),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&status_json_value(
|
||||||
"kind": "status",
|
model,
|
||||||
"model": model,
|
usage,
|
||||||
"permission_mode": permission_mode.as_str(),
|
permission_mode.as_str(),
|
||||||
"usage": {
|
&context,
|
||||||
"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,
|
|
||||||
}
|
|
||||||
}))?
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
Ok(())
|
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(
|
fn status_context(
|
||||||
session_path: Option<&Path>,
|
session_path: Option<&Path>,
|
||||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||||
@@ -4066,27 +4280,31 @@ fn print_sandbox_status_snapshot(
|
|||||||
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
|
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&sandbox_json_value(&status))?
|
||||||
"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,
|
|
||||||
}))?
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||||
match topic {
|
match topic {
|
||||||
LocalHelpTopic::Status => "Status
|
LocalHelpTopic::Status => "Status
|
||||||
@@ -4243,15 +4461,19 @@ fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Er
|
|||||||
CliOutputFormat::Text => println!("{message}"),
|
CliOutputFormat::Text => println!("{message}"),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&init_json_value(&message))?
|
||||||
"kind": "init",
|
|
||||||
"message": message,
|
|
||||||
}))?
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_json_value(message: &str) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"kind": "init",
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
||||||
match mode.trim() {
|
match mode.trim() {
|
||||||
"read-only" => Some("read-only"),
|
"read-only" => Some("read-only"),
|
||||||
@@ -5230,6 +5452,7 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
|||||||
struct AnthropicRuntimeClient {
|
struct AnthropicRuntimeClient {
|
||||||
runtime: tokio::runtime::Runtime,
|
runtime: tokio::runtime::Runtime,
|
||||||
client: AnthropicClient,
|
client: AnthropicClient,
|
||||||
|
session_id: String,
|
||||||
model: String,
|
model: String,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
@@ -5253,6 +5476,7 @@ impl AnthropicRuntimeClient {
|
|||||||
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
|
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
|
||||||
.with_base_url(api::read_base_url())
|
.with_base_url(api::read_base_url())
|
||||||
.with_prompt_cache(PromptCache::new(session_id)),
|
.with_prompt_cache(PromptCache::new(session_id)),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
model,
|
model,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
emit_output,
|
emit_output,
|
||||||
@@ -5292,11 +5516,13 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.runtime.block_on(async {
|
self.runtime.block_on(async {
|
||||||
let mut stream = self
|
let mut stream =
|
||||||
.client
|
self.client
|
||||||
.stream_message(&message_request)
|
.stream_message(&message_request)
|
||||||
.await
|
.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 stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut sink = io::sink();
|
let mut sink = io::sink();
|
||||||
let out: &mut dyn Write = if self.emit_output {
|
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 pending_tool: Option<(String, String, String)> = None;
|
||||||
let mut saw_stop = false;
|
let mut saw_stop = false;
|
||||||
|
|
||||||
while let Some(event) = stream
|
while let Some(event) = stream.next_event().await.map_err(|error| {
|
||||||
.next_event()
|
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||||
.await
|
})? {
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?
|
|
||||||
{
|
|
||||||
match event {
|
match event {
|
||||||
ApiStreamEvent::MessageStart(start) => {
|
ApiStreamEvent::MessageStart(start) => {
|
||||||
for block in start.message.content {
|
for block in start.message.content {
|
||||||
@@ -5409,7 +5633,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
..message_request.clone()
|
..message_request.clone()
|
||||||
})
|
})
|
||||||
.await
|
.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)?;
|
let mut events = response_to_events(response, out)?;
|
||||||
push_prompt_cache_record(&self.client, &mut events);
|
push_prompt_cache_record(&self.client, &mut events);
|
||||||
Ok(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 {
|
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||||
summary
|
summary
|
||||||
.assistant_messages
|
.assistant_messages
|
||||||
@@ -6415,18 +6658,19 @@ mod tests {
|
|||||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
||||||
format_ultraplan_report, format_unknown_slash_command,
|
format_ultraplan_report, format_unknown_slash_command,
|
||||||
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
|
format_unknown_slash_command_message, format_user_visible_api_error,
|
||||||
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
normalize_permission_mode, parse_args, parse_git_status_branch,
|
||||||
permission_policy, print_help_to, push_output_block, render_config_report,
|
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
|
||||||
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
|
print_help_to, push_output_block, render_config_report, render_diff_report,
|
||||||
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
|
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,
|
resume_supported_slash_commands, run_resume_command,
|
||||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
||||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{
|
||||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
||||||
};
|
};
|
||||||
@@ -6466,12 +6710,59 @@ mod tests {
|
|||||||
.expect("plugin tool registry should build")
|
.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 {
|
fn temp_dir() -> PathBuf {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.expect("time should be after epoch")
|
.expect("time should be after epoch")
|
||||||
.as_nanos();
|
.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) {
|
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");
|
let root = unique_temp_dir("inventory-json");
|
||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
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["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"]);
|
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||||
assert_eq!(mcp["kind"], "mcp");
|
assert_eq!(mcp["kind"], "mcp");
|
||||||
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(skills["action"], "list");
|
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]
|
#[test]
|
||||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
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"]);
|
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||||
assert_eq!(doctor["kind"], "doctor");
|
assert_eq!(doctor["kind"], "doctor");
|
||||||
assert!(doctor["message"].is_string());
|
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");
|
let session_path = root.join("session.jsonl");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -130,7 +253,108 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert_eq!(resumed["kind"], "status");
|
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 {
|
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||||
@@ -180,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
|||||||
upstream
|
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 {
|
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||||
let millis = SystemTime::now()
|
let millis = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -261,11 +261,61 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
let parsed: Value =
|
let parsed: Value =
|
||||||
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
||||||
assert_eq!(parsed["kind"], "status");
|
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!(
|
assert_eq!(
|
||||||
parsed["workspace"]["session"],
|
parsed["workspace"]["session"],
|
||||||
session_path.to_str().expect("utf8 path")
|
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 {
|
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||||
|
|||||||
Reference in New Issue
Block a user