From 51b9e6b37ff3f842dbb712f5232de8e0fbc2997b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 30 Apr 2026 09:04:11 +0000 Subject: [PATCH 01/18] Fix export help JSON output --- rust/crates/rusty-claude-cli/src/main.rs | 190 +++++++++++++++--- .../tests/output_format_contract.rs | 36 ++++ 2 files changed, 203 insertions(+), 23 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b..7a9123bc 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -464,7 +464,10 @@ fn run() -> Result<(), Box> { reasoning_effort, allow_broad_cwd, )?, - CliAction::HelpTopic(topic) => print_help_topic(topic), + CliAction::HelpTopic { + topic, + output_format, + } => print_help_topic(topic, output_format)?, CliAction::Help { output_format } => print_help(output_format)?, } Ok(()) @@ -567,7 +570,10 @@ enum CliAction { reasoning_effort: Option, allow_broad_cwd: bool, }, - HelpTopic(LocalHelpTopic), + HelpTopic { + topic: LocalHelpTopic, + output_format: CliOutputFormat, + }, // prompt-mode formatting is only supported for non-interactive runs Help { output_format: CliOutputFormat, @@ -843,7 +849,7 @@ fn parse_args(args: &[String]) -> Result { if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..], output_format); } - if let Some(action) = parse_local_help_action(&rest) { + if let Some(action) = parse_local_help_action(&rest, output_format) { return action; } if let Some(action) = parse_single_word_command_alias( @@ -1021,7 +1027,10 @@ fn parse_args(args: &[String]) -> Result { } } -fn parse_local_help_action(rest: &[String]) -> Option> { +fn parse_local_help_action( + rest: &[String], + output_format: CliOutputFormat, +) -> Option> { if rest.len() != 2 || !is_help_flag(&rest[1]) { return None; } @@ -1044,7 +1053,10 @@ fn parse_local_help_action(rest: &[String]) -> Option> "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, _ => return None, }; - Some(Ok(CliAction::HelpTopic(topic))) + Some(Ok(CliAction::HelpTopic { + topic, + output_format, + })) } fn is_help_flag(value: &str) -> bool { @@ -6090,8 +6102,90 @@ fn render_help_topic(topic: LocalHelpTopic) -> String { } } -fn print_help_topic(topic: LocalHelpTopic) { - println!("{}", render_help_topic(topic)); +fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { + match topic { + LocalHelpTopic::Status => "status", + LocalHelpTopic::Sandbox => "sandbox", + LocalHelpTopic::Doctor => "doctor", + LocalHelpTopic::Acp => "acp", + LocalHelpTopic::Init => "init", + LocalHelpTopic::State => "state", + LocalHelpTopic::Export => "export", + LocalHelpTopic::Version => "version", + LocalHelpTopic::SystemPrompt => "system-prompt", + LocalHelpTopic::DumpManifests => "dump-manifests", + LocalHelpTopic::BootstrapPlan => "bootstrap-plan", + } +} + +fn render_export_help_json() -> serde_json::Value { + json!({ + "kind": "help", + "topic": "export", + "command": "export", + "usage": "claw export [--session ] [--output ] [--output-format ]", + "purpose": "serialize a managed session to JSON for review, transfer, or archival", + "defaults": { + "session": LATEST_SESSION_REFERENCE, + "session_source": ".claw/sessions/", + "output": "derived from the selected session when omitted" + }, + "formats": ["text", "json"], + "options": [ + { + "name": "--session", + "value": "", + "default": LATEST_SESSION_REFERENCE, + "description": "managed session to export" + }, + { + "name": "--output", + "aliases": ["-o"], + "value": "", + "description": "write the exported transcript to this path" + }, + { + "name": "--output-format", + "value": "", + "values": ["text", "json"], + "default": "text", + "description": "format for the command result envelope" + }, + { + "name": "--help", + "aliases": ["-h"], + "description": "show help for the export command" + } + ], + "related": ["/session list", "claw --resume latest"] + }) +} + +fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value { + if topic == LocalHelpTopic::Export { + return render_export_help_json(); + } + + json!({ + "kind": "help", + "topic": local_help_topic_command(topic), + "command": local_help_topic_command(topic), + "message": render_help_topic(topic), + }) +} + +fn print_help_topic( + topic: LocalHelpTopic, + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => println!("{}", render_help_topic(topic)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&render_help_topic_json(topic))? + ), + } + Ok(()) } fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { @@ -9249,17 +9343,17 @@ mod tests { parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, parse_history_count, permission_policy, print_help_to, push_output_block, render_config_report, render_diff_report, render_diff_report_for, render_help_topic, - render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage, - render_session_list, render_session_markdown, resolve_model_alias, - resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference, - response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id, - slash_command_completion_candidates_with_sessions, split_error_hint, status_context, - status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, - validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, - GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, - LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, - SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, - STUB_COMMANDS, + render_help_topic_json, render_memory_report, render_prompt_history_report, + render_repl_help, render_resume_usage, render_session_list, render_session_markdown, + resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model, + resolve_session_reference, response_to_events, resume_supported_slash_commands, + run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions, + split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown, + try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction, + CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent, + InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry, + SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, + DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS, }; use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; use plugins::{ @@ -10379,21 +10473,33 @@ mod tests { assert_eq!( parse_args(&["status".to_string(), "--help".to_string()]) .expect("status help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Status) + CliAction::HelpTopic { + topic: LocalHelpTopic::Status, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["sandbox".to_string(), "-h".to_string()]) .expect("sandbox help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Sandbox) + CliAction::HelpTopic { + topic: LocalHelpTopic::Sandbox, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["doctor".to_string(), "--help".to_string()]) .expect("doctor help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Doctor) + CliAction::HelpTopic { + topic: LocalHelpTopic::Doctor, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Acp) + CliAction::HelpTopic { + topic: LocalHelpTopic::Acp, + output_format: CliOutputFormat::Text, + } ); } @@ -10423,10 +10529,30 @@ mod tests { }); assert_eq!( parsed, - CliAction::HelpTopic(*expected_topic), + CliAction::HelpTopic { + topic: *expected_topic, + output_format: CliOutputFormat::Text, + }, "`{subcommand} {flag}` should resolve to HelpTopic({expected_topic:?})" ); } + let json_parsed = parse_args(&[ + subcommand.to_string(), + "--help".to_string(), + "--output-format".to_string(), + "json".to_string(), + ]) + .unwrap_or_else(|error| { + panic!("`{subcommand} --help --output-format json` should parse: {error}") + }); + assert_eq!( + json_parsed, + CliAction::HelpTopic { + topic: *expected_topic, + output_format: CliOutputFormat::Json, + }, + "`{subcommand} --help --output-format json` should preserve json output format" + ); // And the rendered help must actually mention the subcommand name // (or its canonical title) so users know they got the right help. let rendered = render_help_topic(*expected_topic); @@ -10441,6 +10567,24 @@ mod tests { } } + #[test] + fn export_help_json_is_bounded_and_parseable_384() { + let value = render_help_topic_json(LocalHelpTopic::Export); + assert_eq!(value["kind"], "help"); + assert_eq!(value["topic"], "export"); + assert_eq!(value["command"], "export"); + assert_eq!( + value["usage"], + "claw export [--session ] [--output ] [--output-format ]" + ); + assert_eq!(value["defaults"]["session"], LATEST_SESSION_REFERENCE); + assert!(value["options"].as_array().expect("options array").len() >= 4); + assert!( + value.get("message").is_none(), + "export help json should be a bounded envelope, not plaintext help wrapped in json" + ); + } + #[test] fn status_degrades_gracefully_on_malformed_mcp_config_143() { // #143: previously `claw status` hard-failed on any config parse error, diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 9fbbdcb0..fd3bfe57 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -22,6 +22,42 @@ fn help_emits_json_when_requested() { .contains("Usage:")); } +#[test] +fn export_help_emits_bounded_json_when_requested_384() { + let root = unique_temp_dir("export-help-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]); + assert_eq!(parsed["kind"], "help"); + assert_eq!(parsed["topic"], "export"); + assert_eq!(parsed["command"], "export"); + assert_eq!( + parsed["usage"], + "claw export [--session ] [--output ] [--output-format ]" + ); + assert_eq!(parsed["defaults"]["session"], "latest"); + assert!(parsed["options"].as_array().expect("options").len() >= 4); + assert!(parsed.get("message").is_none()); +} + +#[test] +fn export_help_preserves_plaintext_in_text_mode_384() { + let root = unique_temp_dir("export-help-text"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let output = run_claw(&root, &["export", "--help"], &[]); + 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 utf8"); + assert!(stdout.starts_with("Export\n")); + assert!(stdout.contains("Usage claw export")); + serde_json::from_str::(&stdout).expect_err("text help should remain plaintext"); +} + #[test] fn version_emits_json_when_requested() { let root = unique_temp_dir("version-json"); From 482681cdfec65a867f9bbe35c55dabe23b9ab760 Mon Sep 17 00:00:00 2001 From: Andreas Haida Date: Sun, 3 May 2026 21:21:57 +0200 Subject: [PATCH 02/18] Prune heavy directories during glob searches --- rust/crates/runtime/src/file_ops.rs | 113 ++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index db51215e..ef9a7a05 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -1,4 +1,5 @@ use std::cmp::Reverse; +use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -7,7 +8,7 @@ use std::time::Instant; use glob::Pattern; use regex::RegexBuilder; use serde::{Deserialize, Serialize}; -use walkdir::WalkDir; +use walkdir::{DirEntry, WalkDir}; /// Maximum file size that can be read (10 MB). const MAX_READ_SIZE: u64 = 10 * 1024 * 1024; @@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024; /// Maximum file size that can be written (10 MB). const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024; +const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[ + ".git", + "node_modules", + ".build", + "target", + "dist", + "coverage", +]; + /// Check whether a file appears to contain binary content by examining /// the first chunk for NUL bytes. fn is_binary_file(path: &Path) -> io::Result { @@ -313,14 +323,22 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result io::Result { }) } +fn should_skip_glob_dir(entry: &DirEntry) -> bool { + entry.file_type().is_dir() + && entry + .file_name() + .to_str() + .is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name)) +} + +fn derive_glob_walk_root(pattern: &str) -> PathBuf { + let path = Path::new(pattern); + let mut prefix = PathBuf::new(); + let mut saw_component = false; + + for component in path.components() { + let text = component.as_os_str().to_string_lossy(); + if component_contains_glob(&text) { + break; + } + prefix.push(component.as_os_str()); + saw_component = true; + } + + if saw_component { + prefix + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } +} + +fn component_contains_glob(component: &str) -> bool { + component.contains('*') || component.contains('?') || component.contains('[') +} + fn collect_search_files(base_path: &Path) -> io::Result> { if base_path.is_file() { return Ok(vec![base_path.to_path_buf()]); @@ -651,11 +702,13 @@ fn expand_braces(pattern: &str) -> Vec { #[cfg(test)] mod tests { + use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ - edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file, - read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE, + component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search, + grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file, + GrepSearchInput, MAX_WRITE_SIZE, }; fn temp_path(name: &str) -> std::path::PathBuf { @@ -836,4 +889,50 @@ mod tests { ); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn glob_search_skips_common_heavy_directories() { + let dir = temp_path("glob-ignored-dirs"); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::create_dir_all(dir.join("docs")).unwrap(); + std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap(); + std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap(); + std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap(); + + std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap(); + std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap(); + std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap(); + std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap(); + std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap(); + + let result = + glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed"); + + assert_eq!(result.num_files, 2, "ignored dirs should be pruned"); + assert!(result + .filenames + .iter() + .any(|path| path.ends_with("src/AGENTS.md"))); + assert!(result + .filenames + .iter() + .any(|path| path.ends_with("docs/AGENTS.md"))); + assert!(!result + .filenames + .iter() + .any(|path| path.contains("node_modules") + || path.contains(".build") + || path.contains("/target/"))); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn derive_glob_walk_root_stops_at_first_glob_component() { + let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md"); + assert_eq!(root, PathBuf::from("/tmp/demo")); + assert!(component_contains_glob("**")); + assert!(component_contains_glob("*.rs")); + assert!(!component_contains_glob("src")); + } } From 6ac13ffdad25872a963a688fd26dffe75d01074d Mon Sep 17 00:00:00 2001 From: Andreas Haida Date: Sun, 3 May 2026 18:44:03 +0200 Subject: [PATCH 03/18] Handle OpenAI token-limit errors as context-window failures --- rust/crates/api/src/error.rs | 25 ++++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 33 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index 836f46e0..21d980af 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[ "too many tokens", "prompt is too long", "input is too long", + "input tokens exceed", + "configured limit", + "messages resulted in", + "completion tokens", + "prompt tokens", "request is too large", ]; @@ -542,6 +547,26 @@ mod tests { assert_eq!(error.request_id(), Some("req_ctx_123")); } + #[test] + fn classifies_openai_configured_limit_errors_as_context_window_failures() { + let error = ApiError::Api { + status: reqwest::StatusCode::BAD_REQUEST, + error_type: Some("invalid_request_error".to_string()), + message: Some( + "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages." + .to_string(), + ), + request_id: Some("req_ctx_openai_123".to_string()), + body: String::new(), + retryable: false, + suggested_action: None, + }; + + assert!(error.is_context_window_failure()); + assert_eq!(error.safe_failure_class(), "context_window"); + assert_eq!(error.request_id(), Some("req_ctx_openai_123")); + } + #[test] fn missing_credentials_without_hint_renders_the_canonical_message() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b..e2c889e9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9424,6 +9424,39 @@ mod tests { ); } + #[test] + fn openai_configured_limit_errors_are_rendered_as_context_window_guidance() { + let error = ApiError::Api { + status: "400".parse().expect("status"), + error_type: Some("invalid_request_error".to_string()), + message: Some( + "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages." + .to_string(), + ), + request_id: Some("req_ctx_openai_456".to_string()), + body: String::new(), + retryable: false, + suggested_action: None, + }; + + let rendered = format_user_visible_api_error("session-issue-32", &error); + assert!(rendered.contains("Context window blocked"), "{rendered}"); + assert!(rendered.contains("context_window_blocked"), "{rendered}"); + assert!( + rendered.contains("Trace req_ctx_openai_456"), + "{rendered}" + ); + assert!( + rendered.contains("Detail Input tokens exceed the configured limit of 922000 tokens."), + "{rendered}" + ); + assert!(rendered.contains("Compact /compact"), "{rendered}"); + assert!( + rendered.contains("Fresh session /clear --confirm"), + "{rendered}" + ); + } + #[test] fn retry_wrapped_context_window_errors_keep_recovery_guidance() { let error = ApiError::RetriesExhausted { From 9a512633a54c2345797245fb4b2f0b68ecb3e969 Mon Sep 17 00:00:00 2001 From: Andreas Haida Date: Sun, 3 May 2026 18:46:04 +0200 Subject: [PATCH 04/18] Cap OpenAI default output tokens using model metadata --- rust/crates/api/src/providers/mod.rs | 95 +++++++++++++++++++++--- rust/crates/rusty-claude-cli/src/main.rs | 6 +- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 86871a82..0ef663f1 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -252,17 +252,16 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind { #[must_use] pub fn max_tokens_for_model(model: &str) -> u32 { - model_token_limit(model).map_or_else( - || { - let canonical = resolve_model_alias(model); - if canonical.contains("opus") { - 32_000 - } else { - 64_000 - } - }, - |limit| limit.max_output_tokens, - ) + let canonical = resolve_model_alias(model); + let heuristic = if canonical.contains("opus") { + 32_000 + } else { + 64_000 + }; + + model_token_limit(model) + .map(|limit| heuristic.min(limit.max_output_tokens)) + .unwrap_or(heuristic) } /// Returns the effective max output tokens for a model, preferring a plugin @@ -276,7 +275,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option Option { let canonical = resolve_model_alias(model); - match canonical.as_str() { + let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str()); + match base_model { "claude-opus-4-6" => Some(ModelTokenLimit { max_output_tokens: 32_000, context_window_tokens: 200_000, @@ -289,6 +289,20 @@ pub fn model_token_limit(model: &str) -> Option { max_output_tokens: 64_000, context_window_tokens: 131_072, }), + // GPT-4.1 family via the OpenAI API. + "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit { + max_output_tokens: 32_768, + context_window_tokens: 1_047_576, + }), + // GPT-5.4 family via the OpenAI API. + "gpt-5.4" => Some(ModelTokenLimit { + max_output_tokens: 128_000, + context_window_tokens: 1_000_000, + }), + "gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit { + max_output_tokens: 128_000, + context_window_tokens: 400_000, + }), // Kimi models via DashScope (Moonshot AI) // Source: https://platform.moonshot.cn/docs/intro "kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit { @@ -614,6 +628,15 @@ mod tests { fn keeps_existing_max_token_heuristic() { assert_eq!(max_tokens_for_model("opus"), 32_000); assert_eq!(max_tokens_for_model("grok-3"), 64_000); + assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000); + } + + #[test] + fn caps_default_max_tokens_to_openai_model_limits() { + assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768); + assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768); + assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000); + assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000); } #[test] @@ -680,6 +703,18 @@ mod tests { .context_window_tokens, 131_072 ); + assert_eq!( + model_token_limit("openai/gpt-4.1-mini") + .expect("openai/gpt-4.1-mini should be registered") + .context_window_tokens, + 1_047_576 + ); + assert_eq!( + model_token_limit("gpt-5.4") + .expect("gpt-5.4 should be registered") + .context_window_tokens, + 1_000_000 + ); } #[test] @@ -728,6 +763,42 @@ mod tests { } } + #[test] + fn preflight_blocks_oversized_requests_for_gpt_5_4() { + let request = MessageRequest { + model: "gpt-5.4".to_string(), + max_tokens: 64_000, + messages: vec![InputMessage { + role: "user".to_string(), + content: vec![InputContentBlock::Text { + text: "x".repeat(3_900_000), + }], + }], + system: Some("Keep the answer short.".to_string()), + tools: None, + tool_choice: None, + stream: true, + ..Default::default() + }; + + let error = preflight_message_request(&request) + .expect_err("oversized gpt-5.4 request should be rejected before the provider call"); + + match error { + ApiError::ContextWindowExceeded { + model, + requested_output_tokens, + context_window_tokens, + .. + } => { + assert_eq!(model, "gpt-5.4"); + assert_eq!(requested_output_tokens, 64_000); + assert_eq!(context_window_tokens, 1_000_000); + } + other => panic!("expected context-window preflight failure, got {other:?}"), + } + } + #[test] fn preflight_skips_unknown_models() { let request = MessageRequest { diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e2c889e9..e46ec753 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -148,11 +148,7 @@ impl ModelProvenance { } fn max_tokens_for_model(model: &str) -> u32 { - if model.contains("opus") { - 32_000 - } else { - 64_000 - } + api::max_tokens_for_model(model) } // Build-time constants injected by build.rs (fall back to static values when // build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). From c99330372ca8f67e6c7bf271bb988811db5f2cfa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:20:12 +0900 Subject: [PATCH 05/18] fix(version): add build_date and executable_path to version JSON output `claw version --output-format json` was missing build_date and executable_path, making it impossible to identify which binary is running or correlate it with a specific build/commit. Fix: version_json_value() now includes: - build_date: compile-time BUILD_DATE env (already in text output) - executable_path: std::env::current_exe() at runtime Test: version_emits_json_when_requested extended to assert both fields are strings in the JSON envelope. Pinpoint: ROADMAP #507 --- rust/crates/rusty-claude-cli/src/main.rs | 3 +++ .../rusty-claude-cli/tests/output_format_contract.rs | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b..61ea906b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2627,12 +2627,15 @@ fn print_version(output_format: CliOutputFormat) -> Result<(), Box serde_json::Value { + let executable_path = env::current_exe().ok().map(|p| p.display().to_string()); json!({ "kind": "version", "message": render_version_report(), "version": VERSION, "git_sha": GIT_SHA, "target": BUILD_TARGET, + "build_date": DEFAULT_DATE, + "executable_path": executable_path, }) } diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index e5d8372c..e5ac08e8 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -30,6 +30,15 @@ fn version_emits_json_when_requested() { let parsed = assert_json_command(&root, &["--output-format", "json", "version"]); assert_eq!(parsed["kind"], "version"); assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + // Provenance fields must be present for binary identification (#507). + assert!( + parsed["build_date"].is_string(), + "build_date must be a string in version JSON" + ); + assert!( + parsed["executable_path"].is_string(), + "executable_path must be a string in version JSON so callers can identify which binary is running" + ); } #[test] From 1206f4131d79712feec3233bfabbc28d3bff879b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:20:52 +0900 Subject: [PATCH 06/18] fix(resume): emit structured JSON for /agents --output-format json (#2987) Resumed /agents --output-format json was returning a human-readable text render wrapped in a JSON envelope field instead of the actual structured agent list. The run_resume_command handler was calling handle_agents_slash_command (text) for the json field instead of handle_agents_slash_command_json. Fix: use handle_agents_slash_command_json for the json outcome field, matching the pattern already used by /skills and /plugins. Test: extended resumed_inventory_commands_emit_structured_json_when_requested to cover /agents, asserting kind=="agents", action=="list", agents is an array, and count is a number (not a text render). --- rust/crates/rusty-claude-cli/src/main.rs | 8 +++--- .../tests/output_format_contract.rs | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 61ea906b..1b4acf8f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3526,10 +3526,10 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), - json: Some(serde_json::json!({ - "kind": "agents", - "text": handle_agents_slash_command(args.as_deref(), &cwd)?, - })), + json: Some( + serde_json::to_value(handle_agents_slash_command_json(args.as_deref(), &cwd)?) + .unwrap_or_else(|_| serde_json::json!(null)), + ), }) } SlashCommand::Skills { args } => { diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index e5ac08e8..5aaafab2 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -369,6 +369,34 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() { assert_eq!(skills["action"], "list"); assert!(skills["summary"]["total"].is_number()); assert!(skills["skills"].is_array()); + + let agents = assert_json_command_with_env( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/agents", + ], + &[ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ], + ); + assert_eq!(agents["kind"], "agents"); + assert_eq!(agents["action"], "list"); + assert!( + agents["agents"].is_array(), + "agents field must be a JSON array" + ); + assert!( + agents["count"].is_number(), + "count must be a number, not a text render" + ); } #[test] From 9b97c4d832ca55c77a315f2483807ab0359c221a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:49:46 +0900 Subject: [PATCH 07/18] fix(tests): isolate CLAW_CONFIG_HOME in resumed_status JSON test (#2992) resumed_status_command_emits_structured_json_when_requested was reading the real ~/.claw/settings.json, causing loaded_config_files to be 1 instead of the expected 0 on machines with user config present. Root cause: unlike other tests (e.g. resumed_config_command_loads_settings_files), this test did not pass an isolated CLAW_CONFIG_HOME env var to run_claw, so claw fell back to the real HOME and loaded the developer's settings file. Fix: create a temp config-home dir and pass it as CLAW_CONFIG_HOME via run_claw_with_env. This gives the assertion a clean 0-file baseline. Unblocks PRs #2973, #2988, #2990 which all failed this same test on main. Ref: ROADMAP #65 --- .../crates/rusty-claude-cli/tests/resume_slash_commands.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index 21b8942e..a035544f 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -227,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() { // given let temp_dir = unique_temp_dir("resume-status-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + let config_home = temp_dir.join("config-home"); + fs::create_dir_all(&config_home).expect("isolated config home should exist"); let session_path = temp_dir.join("session.jsonl"); let mut session = workspace_session(&temp_dir); @@ -238,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() { .expect("session should persist"); // when - let output = run_claw( + // Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded, + // which would cause loaded_config_files to be non-zero (#65). + let output = run_claw_with_env( &temp_dir, &[ "--output-format", @@ -247,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() { session_path.to_str().expect("utf8 path"), "/status", ], + &[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))], ); // then From 94b80a05d386aec801a250c98a8ce7b4f631bd94 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:50:30 +0900 Subject: [PATCH 08/18] fix(skills): route show/info/list-filter to local, not model invoke (#2988) `claw skills show `, `claw skills info `, and `claw skills list ` were all falling through to SkillSlashDispatch::Invoke, which spawned a real model session, consumed tokens, and created session files. Root cause: classify_skills_slash_command had no guards for these discovery prefixes; every non-reserved arg became Invoke. Fix: - Add "show", "info" as Local-only bare tokens - Add starts_with guards for "show ", "info ", "list " args - handle_skills_slash_command: filter skill list by name/substring for show/info/list-filter paths (no model call, no session) - handle_skills_slash_command_json: same structured filtering Test: skills_show_and_list_filter_do_not_invoke_model asserts classify_skills_slash_command returns Local for all discovery patterns and still returns Invoke for bare skill names. Pinpoint: ROADMAP #502 --- rust/crates/commands/src/lib.rs | 106 +++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index d4f17706..5b153272 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R let skills = load_skills_from_roots(&roots)?; Ok(render_skills_report(&skills)) } + Some(args) if args.starts_with("list ") => { + let filter = args["list ".len()..].trim().to_lowercase(); + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + let filtered: Vec<_> = skills + .into_iter() + .filter(|s| s.name.to_lowercase().contains(&filter)) + .collect(); + Ok(render_skills_report(&filtered)) + } + Some("show" | "info" | "describe") => { + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report(&skills)) + } + Some(args) + if args.starts_with("show ") + || args.starts_with("info ") + || args.starts_with("describe ") => + { + let name = args + .splitn(2, ' ') + .nth(1) + .unwrap_or_default() + .trim() + .to_lowercase(); + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + let matched: Vec<_> = skills + .into_iter() + .filter(|s| s.name.to_lowercase() == name) + .collect(); + Ok(render_skills_report(&matched)) + } Some("install") => Ok(render_skills_usage(Some("install"))), Some(args) if args.starts_with("install ") => { let target = args["install ".len()..].trim(); @@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std:: let skills = load_skills_from_roots(&roots)?; Ok(render_skills_report_json(&skills)) } + Some(args) if args.starts_with("list ") => { + let filter = args["list ".len()..].trim().to_lowercase(); + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + let filtered: Vec<_> = skills + .into_iter() + .filter(|s| s.name.to_lowercase().contains(&filter)) + .collect(); + Ok(render_skills_report_json(&filtered)) + } + Some("show" | "info" | "describe") => { + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report_json(&skills)) + } + Some(args) + if args.starts_with("show ") + || args.starts_with("info ") + || args.starts_with("describe ") => + { + let name = args + .splitn(2, ' ') + .nth(1) + .unwrap_or_default() + .trim() + .to_lowercase(); + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + let matched: Vec<_> = skills + .into_iter() + .filter(|s| s.name.to_lowercase() == name) + .collect(); + Ok(render_skills_report_json(&matched)) + } Some("install") => Ok(render_skills_usage_json(Some("install"))), Some(args) if args.starts_with("install ") => { let target = args["install ".len()..].trim(); @@ -2419,10 +2487,20 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std:: #[must_use] pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch { match normalize_optional_args(args) { - None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local, + None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => { + SkillSlashDispatch::Local + } Some(args) if args == "install" || args.starts_with("install ") => { SkillSlashDispatch::Local } + Some(args) + if args.starts_with("list ") + || args.starts_with("show ") + || args.starts_with("info ") + || args.starts_with("describe ") => + { + SkillSlashDispatch::Local + } Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))), } } @@ -4619,6 +4697,32 @@ mod tests { assert!(agents_error.contains(" Usage /agents [list|help]")); } + #[test] + fn skills_show_and_list_filter_do_not_invoke_model() { + // `show`, `info`, `list ` must route to Local, not Invoke. + // Regression for: `claw skills show plan` unexpectedly spawned a model session. + for token in &["show", "info", "describe"] { + assert_eq!( + classify_skills_slash_command(Some(token)), + SkillSlashDispatch::Local, + "`skills {token}` alone must be Local" + ); + } + for prefix in &["show ", "info ", "list ", "describe "] { + let arg = format!("{prefix}plan"); + assert_eq!( + classify_skills_slash_command(Some(&arg)), + SkillSlashDispatch::Local, + "`skills {arg}` must be Local, not Invoke" + ); + } + // Bare invocable tokens still dispatch to Invoke. + assert_eq!( + classify_skills_slash_command(Some("plan")), + SkillSlashDispatch::Invoke("$plan".to_string()), + ); + } + #[test] fn accepts_skills_invocation_arguments_for_prompt_dispatch() { assert_eq!( From ac8a24b30b5bc4ec1e1bd00dbf44670db8195df2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:50:33 +0900 Subject: [PATCH 09/18] fix(config): emit section and section_value in JSON output for config subcommands (#2990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claw config model --output-format json` and all other section subcommands (`env`, `hooks`, `plugins`) returned identical output with no section field — the section arg was parsed but discarded (_section parameter). Fix: render_config_json now: - Passes section through to handler - Looks up the section value via runtime_config.get(), converting the internal JsonValue to serde_json::Value via render()+parse - Emits `section` (string) and `section_value` (JSON value or null) in the response envelope - Returns ok:false + error for unsupported section tokens Test: config_section_json_emits_section_and_value asserts: - No section field when no section arg - section + section_value fields present for all known sections - ok:false + error for unknown section Pinpoint: ROADMAP #126 --- rust/crates/rusty-claude-cli/src/main.rs | 45 +++++++++++++++++-- .../tests/output_format_contract.rs | 38 ++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1b4acf8f..ecbf3edd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6210,7 +6210,7 @@ fn render_config_report(section: Option<&str>) -> Result, + section: Option<&str>, ) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); @@ -6243,13 +6243,52 @@ fn render_config_json( }) .collect(); - Ok(serde_json::json!({ + let base = serde_json::json!({ "kind": "config", "cwd": cwd.display().to_string(), "loaded_files": loaded_paths.len(), "merged_keys": runtime_config.merged().len(), "files": files, - })) + }); + + if let Some(section) = section { + let section_rendered: Option = match section { + "env" => runtime_config.get("env").map(|v| v.render()), + "hooks" => runtime_config.get("hooks").map(|v| v.render()), + "model" => runtime_config.get("model").map(|v| v.render()), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")) + .map(|v| v.render()), + other => { + return Ok(serde_json::json!({ + "kind": "config", + "section": other, + "ok": false, + "error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."), + "cwd": cwd.display().to_string(), + "loaded_files": loaded_paths.len(), + "files": files, + })); + } + }; + // Parse the rendered JSON string back into serde_json::Value so that + // section_value is a real JSON object/array in the envelope, not a quoted string. + let section_value: serde_json::Value = section_rendered + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::Value::Null); + let mut obj = base; + let map = obj.as_object_mut().expect("base is object"); + map.insert( + "section".to_string(), + serde_json::Value::String(section.to_string()), + ); + map.insert("section_value".to_string(), section_value); + return Ok(obj); + } + + Ok(base) } fn render_memory_report() -> Result> { diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 5aaafab2..3c7d1f90 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -433,6 +433,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() { assert!(root.join("CLAUDE.md").exists()); } +#[test] +fn config_section_json_emits_section_and_value() { + let root = unique_temp_dir("config-section-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + // Without a section: should return base envelope (no section field). + let base = assert_json_command(&root, &["--output-format", "json", "config"]); + assert_eq!(base["kind"], "config"); + assert!(base["loaded_files"].is_number()); + assert!(base["merged_keys"].is_number()); + assert!( + base.get("section").is_none(), + "no section field without section arg" + ); + + // With a known section: should add section + section_value fields. + for section in &["model", "env", "hooks", "plugins"] { + let result = assert_json_command(&root, &["--output-format", "json", "config", section]); + assert_eq!(result["kind"], "config", "section={section}"); + assert_eq!( + result["section"].as_str(), + Some(*section), + "section field must match requested section, got {result:?}" + ); + assert!( + result.get("section_value").is_some(), + "section_value field must be present for section={section}" + ); + } + + // With an unsupported section: should return ok:false + error field. + let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]); + assert_eq!(bad["kind"], "config"); + assert_eq!(bad["ok"], false); + assert!(bad["error"].as_str().is_some()); + assert!(bad["section"].as_str().is_some()); +} + fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value { assert_json_command_with_env(current_dir, args, &[]) } From 65aa55973345364a2b91eb622d2f55655d5acc61 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 04:55:39 +0900 Subject: [PATCH 10/18] fix: support /plugins slash command in resume mode (#2973) * fix: support /plugins slash command in resume mode Move SlashCommand::Plugins out of the 'unsupported resumed slash command' catch-all and add a handler arm in run_resume_command that calls handle_plugins_slash_command for list/help actions. Mutation actions (install/uninstall/enable/disable) are rejected with a clear error since there is no runtime to reload in resume mode. Add /plugins coverage to resumed_inventory_commands test in output_format_contract.rs: kind, action, reload_runtime, target. Before: claw --resume session.jsonl /plugins --output-format json -> {error: 'unsupported resumed slash command', type: 'error'}, exit 1 After: claw --resume session.jsonl /plugins --output-format json -> {kind: 'plugin', action: 'list', ...}, exit 0 * style: cargo fmt line wrap in run_resume_command plugins handler * fix: block /plugins update in resume mode, fix comment Address REQUEST_CHANGES from OMX review: 1. Add 'update' to the blocked mutation actions in resume mode (previously only install/uninstall/enable/disable were blocked) 2. Fix comment: 'Only list is supported' instead of 'Only list/help' since /plugins help doesn't actually parse as a valid action * style: cargo fmt after conflict resolution --- rust/crates/rusty-claude-cli/src/main.rs | 32 ++++++++++++++++++- .../tests/output_format_contract.rs | 28 ++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ecbf3edd..11539c9a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3545,6 +3545,37 @@ fn run_resume_command( json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), }) } + SlashCommand::Plugins { action, target } => { + // Only list is supported in resume mode (no runtime to reload) + match action.as_deref() { + Some("install") | Some("uninstall") | Some("enable") | Some("disable") + | Some("update") => { + return Err( + "resumed /plugins mutations are interactive-only; start `claw` and run `/plugins` in the REPL".into(), + ); + } + _ => {} + } + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = + handle_plugins_slash_command(action.as_deref(), target.as_deref(), &mut manager)?; + let action_str = action.as_deref().unwrap_or("list"); + let json = serde_json::json!({ + "kind": "plugin", + "action": action_str, + "target": target, + "message": &result.message, + "reload_runtime": result.reload_runtime, + }); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(result.message), + json: Some(json), + }) + } SlashCommand::Doctor => { let report = render_doctor_report()?; Ok(ResumeCommandOutcome { @@ -3631,7 +3662,6 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } - | SlashCommand::Plugins { .. } | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 3c7d1f90..ab2944eb 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -397,6 +397,34 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() { agents["count"].is_number(), "count must be a number, not a text render" ); + + let plugins = assert_json_command_with_env( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/plugins", + ], + &[ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ], + ); + assert_eq!(plugins["kind"], "plugin"); + assert_eq!(plugins["action"], "list"); + assert!( + plugins["reload_runtime"].is_boolean(), + "plugins reload_runtime should be a boolean" + ); + assert!( + plugins["target"].is_null(), + "plugins target should be null when no plugin is targeted" + ); } #[test] From 5eb4b8a944d75cd759f08fa7b60d7bf9694a73c4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 05:13:07 +0900 Subject: [PATCH 11/18] fix(mcp): return typed error JSON for unsupported actions (info/describe/list-filter) (#2989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claw mcp info nonexistent --output-format json` and `claw mcp list nonexistent --output-format json` fell through to the generic help renderer, returning an opaque envelope with only `unexpected` set — no machine-readable error_kind. Fix: - Add typed guards in render_mcp_report_for/_json_for for: - `list `: list accepts no filter argument - `info ` / `describe `: suggest `mcp show` - New render_mcp_unsupported_action_text/json helpers emit `ok:false`, `error_kind:"unsupported_action"`, `hint`, `requested_action` - `mcp show`, `mcp list`, `mcp help` existing paths unchanged Test: mcp_unsupported_actions_return_typed_error_not_generic_help asserts kind=="mcp", ok==false, error_kind=="unsupported_action" for info/list-filter/describe paths. Pinpoint: ROADMAP #504 --- rust/crates/commands/src/lib.rs | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5b153272..454888d7 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2674,10 +2674,44 @@ fn render_mcp_report_for( )), } } + Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => { + // `mcp list ` — list does not accept arguments; treat as unsupported action. + Ok(render_mcp_unsupported_action_text( + args, + "list accepts no filter argument; use `claw mcp list`", + )) + } + Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => { + Ok(render_mcp_unsupported_action_text( + args, + "use `claw mcp show ` to inspect a server", + )) + } Some(args) => Ok(render_mcp_usage(Some(args))), } } +fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String { + format!( + "MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show |help]" + ) +} + +fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value { + json!({ + "kind": "mcp", + "action": "error", + "ok": false, + "error_kind": "unsupported_action", + "requested_action": action, + "hint": hint, + "usage": { + "slash_command": "/mcp [list|show |help]", + "direct_cli": "claw mcp [list|show |help]", + }, + }) +} + fn render_mcp_report_json_for( loader: &ConfigLoader, cwd: &Path, @@ -2758,6 +2792,18 @@ fn render_mcp_report_json_for( })), } } + Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => { + Ok(render_mcp_unsupported_action_json( + args, + "list accepts no filter argument; use `claw mcp list`", + )) + } + Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => { + Ok(render_mcp_unsupported_action_json( + args, + "use `claw mcp show ` to inspect a server", + )) + } Some(args) => Ok(render_mcp_usage_json(Some(args))), } } @@ -4745,6 +4791,38 @@ mod tests { ); } + #[test] + fn mcp_unsupported_actions_return_typed_error_not_generic_help() { + // `mcp info ` and `mcp list ` must return typed errors, not raw help. + // Regression for #504: these previously fell through to render_mcp_usage with + // unexpected=arg, giving no machine-readable error_kind. + use crate::handle_mcp_slash_command_json; + use std::path::PathBuf; + let cwd = PathBuf::from("/tmp"); + + let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd) + .expect("info nonexistent should not error at IO level"); + assert_eq!(info_json["kind"], "mcp"); + assert_eq!(info_json["ok"], false); + assert_eq!(info_json["error_kind"], "unsupported_action"); + assert!(info_json["hint"] + .as_str() + .unwrap_or_default() + .contains("show")); + + let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd) + .expect("list nonexistent should not error at IO level"); + assert_eq!(list_filter_json["kind"], "mcp"); + assert_eq!(list_filter_json["ok"], false); + assert_eq!(list_filter_json["error_kind"], "unsupported_action"); + + let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd) + .expect("describe myserver should not error at IO level"); + assert_eq!(describe_json["kind"], "mcp"); + assert_eq!(describe_json["ok"], false); + assert_eq!(describe_json["error_kind"], "unsupported_action"); + } + #[test] fn rejects_invalid_mcp_arguments() { let show_error = parse_error_message("/mcp show alpha beta"); From 85435ad4b50cd393dec4835e72342f1448c8a4e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 05:16:00 +0900 Subject: [PATCH 12/18] fix(plugins): route plugin and marketplace aliases through local handler (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claw plugin list / claw marketplace / claw marketplace list all fell through to the prompt/LLM path because parse_subcommand only matched "plugins" (the primary name) while the canonical spec aliases "plugin" and "marketplace" were unhandled. This manifested as auth errors and session creation on direct invocation — dogfood confirmed Gaebal's binary created one session via plugin prompt fallback. Fix: extend the plugins arm in parse_subcommand to also match "plugin" | "marketplace" so all three forms route to the same CliAction::Plugins without network calls or session creation. Verified: all six forms (bare + list subcommand for each name) return kind:plugin JSON, exit 0, and create zero sessions. Closes ROADMAP #55 partial (plugins/marketplace bypass complete). --- rust/crates/rusty-claude-cli/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 11539c9a..379e8c0c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -877,13 +877,17 @@ fn parse_args(args: &[String]) -> Result { // `missing Anthropic credentials` even though the command is purely // local introspection. Mirror `agents`/`mcp`/`skills`: action is the // first positional arg, target is the second. - "plugins" => { + // `plugin` (singular) and `marketplace` are aliases for `plugins`. + // All three must route to the same local handler so that no form + // falls through to the LLM/prompt path. + "plugins" | "plugin" | "marketplace" => { let tail = &rest[1..]; let action = tail.first().cloned(); let target = tail.get(1).cloned(); if tail.len() > 2 { return Err(format!( - "unexpected extra arguments after `claw plugins {}`: {}", + "unexpected extra arguments after `claw {} {}`: {}", + rest[0], tail[..2].join(" "), tail[2..].join(" ") )); From caeac828b58990795b693a739f11df45aa688f31 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 05:35:50 +0900 Subject: [PATCH 13/18] fix(permissions): return guidance for multi-word forms instead of falling through to LLM (#2994) claw permissions list / claw permissions allow / claw permissions deny all fell through to the prompt/LLM path because parse_subcommand had no arm for "permissions". The single-word bare form was already intercepted by bare_slash_command_guidance, but any form with rest.len() > 1 bypassed the single-word guard and landed in the _other => CliAction::Prompt branch. Fix: add a "permissions" arm in parse_subcommand that returns a structured guidance Err so all multi-word forms get the same exit:1 + JSON error as the bare single-word form, without any LLM call or session creation. Verified: all invocation forms (bare, list, read-only, workspace-write, allow/deny ) exit 1 with kind:unknown guidance JSON. Zero sessions. --- rust/crates/rusty-claude-cli/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 379e8c0c..d3ee789b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -930,6 +930,14 @@ fn parse_args(args: &[String]) -> Result { } Ok(CliAction::Diff { output_format }) } + // `claw permissions ` falls through to the LLM when called + // with a subcommand argument because parse_single_word_command_alias + // only intercepts the bare single-word form. Catch all multi-word + // forms here and return a structured guidance error so no network + // call or session is created. + "permissions" => Err(format!( + "`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]" + )), "skills" => { let args = join_optional_args(&rest[1..]); match classify_skills_slash_command(args.as_deref()) { From d074d1c046ea2dda8f151a31e19ddfa0f7e96387 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 06:09:11 +0900 Subject: [PATCH 14/18] fix(mcp): exit 1 when JSON envelope contains ok:false (#2995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): exit 1 when JSON envelope contains ok:false mcp info, mcp describe, and mcp list-filter all return {"action":"error","ok":false,...} but previously exited 0, requiring automation callers to inspect the envelope field. After this fix: print_mcp detects ok:false in the rendered JSON value and calls process::exit(1) after printing, so the exit code reflects the semantic error in the envelope. Unaffected: mcp list, mcp show, mcp help all have no ok field and continue to exit 0 (they are not error paths). Closes ROADMAP #68 (partial — agents bogus/mcp show nonexistent found:false remain exit:0 as they use different envelope shapes). * feat(scripts): add dogfood-build.sh — build from checkout and verify provenance Builds claw from the current HEAD, then checks that the binary's git_sha matches git rev-parse --short HEAD. Exits non-zero if the binary is stale or provenance is opaque (git_sha: null). Usage: CLAW=$(bash scripts/dogfood-build.sh) # fail-fast if stale $CLAW version --output-format json # provenance confirmed Addresses ROADMAP #69: dogfooders using a stale installed binary cannot attribute behavior to specific commits. This script makes dogfood round zero unambiguous. Also documents the safe workaround for contributors who have a stale system-installed binary. --- rust/crates/rusty-claude-cli/src/main.rs | 15 +++++++--- scripts/dogfood-build.sh | 38 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100755 scripts/dogfood-build.sh diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d3ee789b..a4ff9bc9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -5107,10 +5107,17 @@ impl LiveCli { let cwd = env::current_dir()?; match output_format { CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd)?)? - ), + CliOutputFormat::Json => { + let value = handle_mcp_slash_command_json(args, &cwd)?; + // Propagate ok:false → non-zero exit so automation callers + // can rely on exit code instead of inspecting the envelope. + // (#68: mcp error envelopes previously always exited 0.) + let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false); + println!("{}", serde_json::to_string_pretty(&value)?); + if is_error { + std::process::exit(1); + } + } } Ok(()) } diff --git a/scripts/dogfood-build.sh b/scripts/dogfood-build.sh new file mode 100755 index 00000000..5617a27e --- /dev/null +++ b/scripts/dogfood-build.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# dogfood-build.sh — Build claw from current checkout and verify provenance. +# Usage: bash scripts/dogfood-build.sh +# On success: prints the verified binary path. Use as: +# CLAW=$(bash scripts/dogfood-build.sh) && $CLAW version --output-format json +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUST_DIR="$REPO_ROOT/rust" +BINARY="$RUST_DIR/target/debug/claw" +EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" + +echo "▶ Building claw from $REPO_ROOT ($(git -C "$REPO_ROOT" log --oneline -1))..." >&2 +cargo build --manifest-path "$RUST_DIR/Cargo.toml" -p rusty-claude-cli -q + +if [[ ! -x "$BINARY" ]]; then + echo "✗ Build succeeded but binary not found at $BINARY" >&2 + exit 1 +fi + +BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha','null'))" 2>/dev/null || echo "null") + +if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then + echo "✗ Provenance check failed: binary reports git_sha: null" >&2 + echo " Binary: $BINARY" >&2 + exit 1 +fi + +if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then + echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2 + echo " Rerun after 'git pull' or check for uncommitted changes." >&2 + exit 1 +fi + +echo "✓ Binary verified: $BINARY_SHA == HEAD ($EXPECTED_SHA)" >&2 +echo " To dogfood: export CLAW=$BINARY" >&2 +echo "$BINARY" From ab44985916cb0d53d2f7a55ea90e0d7be97d4626 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 07:02:13 +0900 Subject: [PATCH 15/18] fix(scripts): inject GIT_SHA in dogfood-build.sh so provenance check passes (#2996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts-only PR — CI intentionally does not run for scripts/ (path filter covers rust/** and docs only). Manually verified: dogfood-build.sh builds, injects GIT_SHA, verifies provenance, and documents CLAW_CONFIG_HOME isolation. Zero stderr with isolated config. --- scripts/dogfood-build.sh | 52 +++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/scripts/dogfood-build.sh b/scripts/dogfood-build.sh index 5617a27e..7643dc9d 100755 --- a/scripts/dogfood-build.sh +++ b/scripts/dogfood-build.sh @@ -1,8 +1,16 @@ #!/usr/bin/env bash # dogfood-build.sh — Build claw from current checkout and verify provenance. -# Usage: bash scripts/dogfood-build.sh -# On success: prints the verified binary path. Use as: -# CLAW=$(bash scripts/dogfood-build.sh) && $CLAW version --output-format json +# +# Injects GIT_SHA at build time so version JSON is non-null. +# Suppresses Cargo compile noise on stderr. +# Prints the verified binary path on success. Use as: +# +# CLAW=$(bash scripts/dogfood-build.sh) +# +# Then dogfood with config isolation (avoids real user config bleeding in): +# +# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json +# set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -10,29 +18,51 @@ RUST_DIR="$REPO_ROOT/rust" BINARY="$RUST_DIR/target/debug/claw" EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" -echo "▶ Building claw from $REPO_ROOT ($(git -C "$REPO_ROOT" log --oneline -1))..." >&2 -cargo build --manifest-path "$RUST_DIR/Cargo.toml" -p rusty-claude-cli -q +echo "▶ Building claw from $REPO_ROOT" >&2 +echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2 + +# Inject GIT_SHA so version JSON returns a non-null sha. +# Redirect cargo stderr to /dev/null to suppress compile noise; +# on build failure cargo exits non-zero and set -e aborts. +if ! GIT_SHA="$EXPECTED_SHA" cargo build \ + --manifest-path "$RUST_DIR/Cargo.toml" \ + -p rusty-claude-cli -q 2>/dev/null; then + # Re-run with visible output so the user sees the error + echo "✗ Build failed — rerunning with output:" >&2 + GIT_SHA="$EXPECTED_SHA" cargo build \ + --manifest-path "$RUST_DIR/Cargo.toml" \ + -p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2 + exit 1 +fi if [[ ! -x "$BINARY" ]]; then - echo "✗ Build succeeded but binary not found at $BINARY" >&2 + echo "✗ Binary not found at $BINARY" >&2 exit 1 fi BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \ - | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha','null'))" 2>/dev/null || echo "null") + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \ + || echo "null") if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then echo "✗ Provenance check failed: binary reports git_sha: null" >&2 - echo " Binary: $BINARY" >&2 exit 1 fi if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2 - echo " Rerun after 'git pull' or check for uncommitted changes." >&2 exit 1 fi -echo "✓ Binary verified: $BINARY_SHA == HEAD ($EXPECTED_SHA)" >&2 -echo " To dogfood: export CLAW=$BINARY" >&2 +echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2 +echo "" >&2 +echo " export CLAW=$BINARY" >&2 +echo "" >&2 +echo " Dogfood with isolated config (no real user config on stderr):" >&2 +echo " CLAW_ISOLATED=\$(mktemp -d)" >&2 +echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2 +echo " rm -rf \$CLAW_ISOLATED" >&2 +echo "" >&2 +echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2 +echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2 echo "$BINARY" From 553d25ee502e81556f4ca94e3b44efe06b2cf94d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 6 May 2026 15:00:34 +0900 Subject: [PATCH 16/18] docs(roadmap): consolidate items #407-#420 from 12 conflicting PRs Batch-appended ROADMAP entries from PRs #2950, #2951, #2953, #2954, #2955, #2956, #2957, #2959, #2960, #2962, #2963, #2964. All PRs were CI-green but conflicting on ROADMAP.md due to serial appends to the same file. --- ROADMAP.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 97d79b71..ef302987 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6302,3 +6302,39 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 381. **Top-level `cache --help --output-format json` hangs with zero stdout/stderr instead of returning bounded command help JSON** — dogfooded 2026-04-30 for the 03:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `d95b230c`. After #358 and #380 landed for the cost/tokens preflight help hangs, a fresh adjacent probe on the cache-control surface showed the same silent failure class: repeated bounded runs of `timeout --kill-after=1s 8s ./rust/target/debug/claw cache --help --output-format json` exited `124` with `stdout=0` and `stderr=0`. In the same rebuilt binary, `version --output-format json` returned promptly with version/build metadata, proving the binary itself and JSON output path are reachable. This is distinct from the separate `/cache` slash-command envelope mismatch class: the affected surface here is top-level `cache` command help, where agents need bounded local discovery before deciding whether to inspect, clear, or summarize cache state. **Required fix shape:** (a) make `cache --help --output-format json` return static/bounded stdout JSON with `kind:"help"` or `kind:"cache"`, `action:"help"`, usage, options, examples, supported output formats, and related slash/direct commands; (b) ensure help rendering does not initialize slow cache/session/provider state; (c) if any dynamic provider is consulted, return a typed JSON timeout/unavailable error instead of hanging; (d) add regression coverage proving cache help in JSON mode returns within a deterministic budget. **Why this matters:** cache inspection and cleanup are recovery/control-plane operations. If cache help hangs silently, claws cannot safely discover cache semantics before attempting cleanup, and automation stalls before it can choose a non-destructive cache action. Source: gaebal-gajae dogfood follow-up for the 03:00 nudge on rebuilt `./rust/target/debug/claw` `d95b230c`. 422. **`export --output-format json` and `--resume latest` report the same "no managed sessions" scenario using two different `kind` codes — `no_managed_sessions` vs `session_load_failed` — making "no session found" undetectable by a single kind-code check** — dogfooded 2026-04-30 KST (UTC+9) by Jobdori on `e939777f`. Running `claw export --output-format json` with no session present returns (on stderr, exit 1): `{"error":"no managed sessions found in .claw/sessions//","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"no_managed_sessions","type":"error"}`. Running `claw --resume latest /status --output-format json` with no session present returns (on stderr, exit 1): `{"error":"failed to restore session: no managed sessions found in .claw/sessions//","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"session_load_failed","type":"error"}`. Both describe the same root condition — there are no sessions to operate on — but they expose it via different `kind` discriminants. Automation that checks `kind == "no_managed_sessions"` to detect a cold workspace will miss the `--resume` path's `session_load_failed`, and vice versa. A wrapper that guards "run with --resume only if a session exists" must special-case both codes. The hint text is identical between them, suggesting the messages are logically equivalent. Additionally neither code matches the proposed canonical names `session_not_found` / `session_load_failed` as stable `ErrorKind` discriminants described in ROADMAP #77's fix shape, which explicitly proposes typed error-kind codes for session lifecycle failures. **Required fix shape:** (a) unify "no sessions found for this workspace fingerprint" under a single canonical `kind` code — either `no_managed_sessions` or `session_not_found` — used consistently by every command path that encounters an empty session registry; (b) if `session_load_failed` is a more general category (covering e.g. corrupt session files, IO errors, schema version mismatches), it should nest a concrete `reason:"no_managed_sessions"` or `reason:"session_not_found"` sub-field so callers can distinguish "empty registry" from "found but unreadable"; (c) align with the canonical error-kind contract proposed in #77; (d) add regression coverage proving `export` and `--resume latest` in an empty workspace both return an error with the same top-level `kind` code. **Why this matters:** session guard-rails in orchestration need a single stable `kind` to detect cold workspaces without enumerating all possible no-session synonyms. Two divergent codes for the same condition make defensive automation brittle and contradict the promise of machine-readable error envelopes. Source: Jobdori live dogfood, `e939777f`, 2026-04-30 KST (UTC+9). + +407. **`config --output-format json` returns `files[].loaded:false` with no `load_error`, `not_found`, or `skip_reason` field — automation cannot distinguish "file does not exist", "file exists but parse failed", and "file exists but was skipped by policy" from the same `loaded:false` value; also `loaded_files` and `merged_keys` are bare integers with no per-file attribution** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json config` on a workspace with 5 discovered config files returns `{"kind":"config","cwd":"...","files":[{"loaded":false,"path":"/Users/yeongyu/.claw.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/.claw/settings.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/clawd/claw-code/.claw.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.local.json","source":"local"}],"loaded_files":2,"merged_keys":2}`. Three of five files have `loaded:false` with no accompanying `not_found:true`, `parse_error`, `io_error`, or `skip_reason`; automation must stat each path separately to guess why. Also `loaded_files:2` and `merged_keys:2` are bare counts — ambiguous whether `merged_keys:2` means 2 total top-level JSON keys across all files or 2 unique merged settings. **Required fix shape:** (a) add `not_found: bool` and optional `load_error: string` to each `files[]` entry so callers can distinguish missing, parse-broken, and policy-skipped files without filesystem probing; (b) document or rename `merged_keys` as `merged_setting_count` or `total_merged_keys` to remove the int-semantics ambiguity; (c) optionally add `merged_keys_by_file: [{path, keys}]` for attribution; (d) add regression coverage proving `files[]` entries with `loaded:false` carry at minimum `not_found` distinguishing non-existent paths from load failures. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +408. **`status --output-format json` `workspace.changed_files` is ambiguous — on a workspace with 5 untracked files, `changed_files:5`, `staged_files:0`, `unstaged_files:0`, `untracked_files:5`; it is unclear whether `changed_files` is the sum of all four git-status categories or only a subset; automation cannot tell if `changed_files:5` means "5 tracked modified" or "5 total non-clean files including untracked"** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json status` returns `{"workspace":{"changed_files":5,"staged_files":0,"unstaged_files":0,"untracked_files":5,...}}` — `changed_files==untracked_files==5` with staged and unstaged both zero. The field name `changed_files` implies "modified tracked files" but the value equals the untracked count, not `staged+unstaged`. Without a comment or documented definition, automation must probe whether `changed_files = staged + unstaged` (excludes untracked) or `changed_files = staged + unstaged + untracked + conflicted` (total dirty). Also `git_state:"dirty · 5 files · 5 untracked"` repeats the same data as a prose string alongside the structured integer fields — redundant human-readable string alongside machine-readable integers. **Required fix shape:** (a) document and stabilize `changed_files` as either `tracked_dirty_count` (staged+unstaged only) or `total_non-clean_count` (staged+unstaged+untracked+conflicted) and rename to remove the ambiguity; (b) ensure a machine consumer can compute `is_clean` as a single boolean field without interpreting `git_state` prose; (c) deprecate or remove `git_state` prose string now that all its constituent counts are available as integers; (d) add regression coverage proving `changed_files` semantics against a workspace with staged, unstaged, untracked, and conflicted files. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +409. **`init --output-format json` emits redundant parallel artifact schemas — `artifacts[].status` and flat `created[]`/`skipped[]`/`updated[]` arrays carry identical state, and `artifacts[].status:"skipped"` omits `skip_reason`** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw init --output-format json` on a fresh directory returns a JSON object with two parallel representations of the same artifact set: (1) `artifacts: [{name, status}]` — a structured per-artifact array; and (2) `created: [...]`, `skipped: [...]`, `updated: [...]` — flat string arrays partitioned by status. Both encode the same four artifact names and their outcomes with no additional information between them. On a subsequent run in an already-initialized directory, every artifact has `status:"skipped"`, but no `reason` field is present on any artifact entry — automation cannot distinguish `"already_exists"` (safe to ignore) from `"permission_denied"`, `"dry_run"`, or `"conflicting_contents"` (each requiring a different response). The `message` field also embeds `"skipped (already exists)"` prose that is absent from the structured payload. **Required fix shape:** (a) pick one canonical artifact representation — either `artifacts[{name, status, reason?, path?}]` or the flat status arrays — and deprecate the other; (b) add a `skip_reason` or `reason` field to `artifacts[]` entries with `status:"skipped"` and `status:"error"`, using an enum such as `already_exists`, `permission_denied`, `dry_run`, `conflict`, `unknown`; (c) add optional `path` (absolute) to each artifact entry so automation can act on the real on-disk location without re-joining with `project_path`; (d) add regression coverage proving `init --output-format json` on an existing directory includes machine-classifiable skip reasons for every skipped artifact and does not rely on the prose `message` field for structured state. **Why this matters:** init is the bootstrapping surface automation uses to ensure a project is claw-ready. If skip classification requires parsing human prose and the structured payload has two redundant formats, claws either over-provision re-inits or cannot distinguish safe skips from blocked writes without brittle message scraping. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +410. **`agents list`, `skills list`, and `mcp list` use three different count-field names and divergent envelope schemas despite being sibling list commands** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running all three list commands with `--output-format json` reveals incompatible envelope shapes: `agents list` emits `count:int` at the top level plus `summary:{active,shadowed,total}` and `working_directory`; `skills list` emits no top-level `count`, only `summary:{active,shadowed,total}`, and omits `working_directory`; `mcp list` uses a different count-field name `configured_servers:int`, has no `count`, no `summary`, and instead adds `status:"ok"` and `config_load_error:null` fields absent from the other two. The three sibling commands cannot be polymorphically consumed with the same count-extraction logic, requiring per-command special-casing at the cardinality check level. **Required fix shape:** (a) define one canonical top-level count field name (`count`, `total`, or `item_count`) and use it across `agents`, `skills`, and `mcp` list envelopes; (b) define one canonical `summary` object shape with at minimum `active`, `total`, and optionally `shadowed` and include it on all three; (c) expose `working_directory` consistently on all list commands or omit it from all; (d) add regression coverage proving the three list envelopes share the same count-field name and summary shape before each release. **Why this matters:** orchestration lanes that inventory agents, skills, and servers before delegation need one count-extraction pattern. Three different field names force per-command special-casing of the most basic cardinality check. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +411. **`plugins enable/disable --output-format json` always emits `reload_runtime:true` regardless of whether state actually changed, and omits `previous_status`, `changed`, `version`, and `source` fields — automation cannot tell if a reload is necessary or if the mutation was a no-op** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw plugins enable example-bundled --output-format json` on an already-enabled plugin returns `{"action":"enable","kind":"plugin","message":"…","reload_runtime":true,"target":"example-bundled"}` — `reload_runtime:true` every time, even on a no-op re-enable. The same applies to idempotent `disable`. Structured fields present: `action`, `kind`, `message`, `reload_runtime`, `target`. Structured fields absent: `previous_status`, `status`, `changed`, `version`, `source`. The actual plugin name, version, and new status are embedded only in the prose `message` field (`"Result enabled example-bundled@bundled\n Name example-bundled\n Version 0.1.0\n Status enabled"`), requiring callers to scrape column-aligned text to extract the post-mutation state. A no-op mutation emitting `reload_runtime:true` forces orchestration to trigger an expensive runtime reload even when no config change occurred. **Required fix shape:** (a) add `changed:bool` so callers can skip runtime reload when `changed:false`; (b) add `previous_status` and `status` fields (enums: `enabled`/`disabled`) so pre/post state is machine-readable without parsing `message`; (c) add `version` and `source` fields at the mutation response level, consistent with `plugins list` entry shape; (d) emit `reload_runtime:false` when `changed:false`; (e) add regression coverage proving idempotent enable/disable sets `changed:false` and `reload_runtime:false`. **Why this matters:** plugin lifecycle is a hot path for automation that conditionally enables plugins before running sessions. If every enable emits `reload_runtime:true` and no `changed` field exists, orchestration must reload unconditionally or maintain external state — both brittle patterns. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +412. **`bootstrap-plan --output-format json` returns `phases: string[]` of raw Rust enum variant names with no description, steps, duration, or dependency metadata — unusable by automation** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw bootstrap-plan --output-format json` returns `{"kind":"bootstrap-plan","phases":["CliEntry","FastPathVersion","StartupProfiler","SystemPromptFastPath","ChromeMcpFastPath","DaemonWorkerFastPath","BridgeFastPath","DaemonFastPath","BackgroundSessionFastPath","TemplateFastPath","EnvironmentRunnerFastPath","MainRuntime"]}`. The envelope has only two keys: `kind` and `phases`. The `phases` array contains 12 raw Rust enum variant name strings — opaque identifiers with no `description`, no `label`, no `steps[]`, no `estimated_ms`, no `dependencies[]`, no `optional:bool`, and no `status` (enabled/disabled/skipped). Automation that calls `bootstrap-plan` to understand startup costs or profile initialization paths receives 12 name strings that reveal nothing about what each phase does, how long it takes, whether it depends on credentials/network/MCP, or which ones can be skipped. **Required fix shape:** (a) replace `phases: string[]` with `phases: [{id, label, description, optional, estimated_ms?, dependencies?, status?}]`; (b) add a top-level `total_phases` count; (c) mark network/credential-dependent phases with a `requires_auth:bool` or `deps:["network","credentials","mcp"]` field so automation can plan for unavailability; (d) add regression coverage proving each phase entry has at least `id`, `label`, and `description` fields and that the count matches the phases array length. **Why this matters:** bootstrap-plan is the startup-cost introspection surface. If its JSON output is 12 opaque variant name strings, automation cannot profile startup, identify slow phases, skip optional phases, or present meaningful startup diagnostics — the entire command serves only as a list of internal identifiers. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +413. **`acp --output-format json` leaks internal ROADMAP tracking numbers and implementation notes as top-level JSON fields — `discoverability_tracking:"ROADMAP #64a"` and `tracking:"ROADMAP #76"` are internal backlog references that should not appear in the public machine-readable contract** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw acp --output-format json` returns a ten-key envelope: `aliases`, `discoverability_tracking`, `kind`, `launch_command`, `message`, `recommended_workflows`, `serve_alias_only`, `status`, `supported`, `tracking`. Two fields are verbatim internal backlog cross-references: `"discoverability_tracking":"ROADMAP #64a"` and `"tracking":"ROADMAP #76"`. These were presumably used during initial scaffolding to track which backlog items the stub relates to, but they are now part of the public JSON contract that automation consumes. The `message` field also contains implementation-note prose (`"ACP/Zed editor integration is not implemented in claw-code yet. \`claw acp serve\`..."`) that describes the build state rather than the command's machine-readable status. **Required fix shape:** (a) remove `discoverability_tracking` and `tracking` from the public JSON envelope or move them to an optional `_debug` or `_meta` sub-object gated on a debug flag; (b) replace `message` prose with a structured `reason` enum (`"not_implemented"`, `"discoverability_only"`, `"serve_only"`) plus optional `detail` string; (c) rename `supported:false` + `status:"discoverability_only"` to a single typed `availability` object with `status`, `reason`, and `target_command` fields; (d) add regression coverage proving the public `acp --output-format json` envelope contains no internal tracking/backlog fields and that `message` is not the sole machine-classifiable signal. **Why this matters:** public JSON APIs should not leak internal ticket references. Automation that snapshots or validates the ACP JSON schema will embed these internal identifiers into external contracts and need to change every time backlog numbering shifts. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +415. **`config
--output-format json` returns `merged_keys:int` (a count) with no actual merged key-value pairs — automation cannot read the resolved configuration values from JSON** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw config env --output-format json`, `claw config model --output-format json`, or `claw config hooks --output-format json` all return an identical five-key envelope: `{"cwd":"...","files":[...],"kind":"config","loaded_files":2,"merged_keys":1}`. The `merged_keys` field is an integer count of how many keys were merged across the loaded files, not an object or array of the actual key names and resolved values. The `files` array shows which config files were loaded/missing but contains no per-file key-value content. The merged section content — the actual resolved `env`, `model`, or `hooks` configuration — is entirely absent from the JSON output. It only appears in the prose output as a "Merged section: env / " block. **Required fix shape:** (a) add a `merged` or `resolved` object/array field to the JSON envelope containing the actual key-value pairs that resulted from merging the loaded config files for the requested section; (b) rename `merged_keys` from an integer count to either remove it (derivable from `len(merged)`) or keep it as a companion count field; (c) for each entry in `merged`, include `key`, `value`, and optionally `source_file` so automation can attribute which file contributed the value; (d) add regression coverage proving `config env --output-format json` with a non-empty env section populates `merged` (or equivalent) with the actual resolved key-value pairs. **Why this matters:** the entire purpose of `config env/model/hooks --output-format json` is to allow automation to read the resolved runtime configuration without screen-scraping prose. Returning only a count defeats the purpose and forces callers to either re-parse the prose output or re-read and merge the source config files themselves. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +416. **`plugins list --output-format json` returns the mutation response shape with a prose `message` table instead of a structured `plugins:[]` array — `name`, `version`, `status`, `source` are embedded in `message` prose only** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw plugins list --output-format json` returns `{"action":"list","kind":"plugin","message":"Plugins\n example-bundled v0.1.0 disabled\n sample-hooks v0.1.0 disabled","reload_runtime":false,"target":null}`. This is the same four-key response envelope used by `plugins enable` and `plugins disable` mutation commands, not a list envelope. The `message` field contains the full rendered prose table (plugin name, version, and status as whitespace-aligned columns), but no `plugins` array with structured per-entry objects. `target` is `null` because no specific plugin was targeted. The `reload_runtime:false` field is meaningless for a read-only list operation. **This is distinct from ROADMAP #411** which covers the mutation commands' own missing `changed`/`previous_status`/`version`/`source` fields — #416 targets the list command's structural mismatch: it uses the mutation envelope entirely instead of emitting a dedicated list schema. **Required fix shape:** (a) emit a distinct `{kind:"plugin_list", plugins:[{name, version, status, source, path?, description?}], count}` envelope for the `list` action; (b) omit `action`, `reload_runtime`, and `target` from list responses (mutation-only fields); (c) the `message` field should be absent or optional and must not be the sole machine-readable inventory surface; (d) add regression coverage proving `plugins list --output-format json` populates a `plugins` array with at least `name`, `version`, and `status` fields for each installed plugin. **Why this matters:** automation that calls `plugins list --output-format json` to discover installed plugin inventory receives only a whitespace-aligned prose table in a string field, with `reload_runtime:false` and `target:null` as the only other machine-readable signals — identical noise to what a failed enable command returns. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +418. **`system-prompt --output-format json` exposes `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` as a literal element in the `sections` array — an internal split delimiter leaked into the public structured output** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw system-prompt --output-format json` returns `{"kind":"system-prompt","message":"","sections":["You are an interactive agent...", "# System\n...", "# Doing tasks\n...", "# Executing actions with care\n...", "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__", "# Environment context\n...", "# Project context\n...", "# Claude instructions\n...", "# Runtime config\n..."]}`. The `sections` array has 9 elements; element index 4 is the raw string `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"`. This internal sentinel marks the boundary between the static and dynamic sections of the compiled system prompt, used during assembly to split the prompt at injection time. It appears in the public JSON output verbatim as a first-class section, indistinguishable from real sections by type alone. Automation that iterates `sections[]` must special-case this sentinel or it will process an internal implementation string as if it were a real system prompt section. **Required fix shape:** (a) strip `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` and any similar internal delimiters from the `sections` array before serializing to JSON; (b) if the static/dynamic boundary is semantically meaningful for callers, expose it as a structured metadata field such as `boundary_index:4` or as a `section_type:"static"|"dynamic"` field on each section entry, not as a raw sentinel string in the array; (c) rename the `sections` type from `string[]` to `[{id, type, content}]` to enable this without breaking the boundary signal; (d) add regression coverage proving the `system-prompt --output-format json` output's `sections` array contains no elements whose value equals `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` or matches `/__[A-Z_]+__/`. **Why this matters:** internal sentinel strings in public JSON are a contract liability — they couple the wire format to internal implementation details. Any refactor that renames or removes the sentinel breaks callers that don't special-case it, and automation that doesn't know to filter it will miscount, misparse, or misrender the system prompt. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. + + +419. **`mcp --output-format json` returns `action:"help"` + `unexpected:` with exit 0 instead of an error envelope — unrecognized MCP subcommands silently succeed** — dogfooded 2026-05-01 by Jobdori on `e939777f`. Running `claw mcp add --output-format json` or `claw mcp remove --output-format json` (subcommands that do not exist) returns exit 0 with stdout JSON `{"action":"help","kind":"mcp","unexpected":"add","usage":{"direct_cli":"claw mcp [list|show |help]","slash_command":"/mcp [list|show |help]","sources":[...]}}`. Exit code is 0. The `action` field is `"help"` — not `"error"` — even though the caller issued a recognized token (`add`/`remove`) that maps to a real but unimplemented feature. The `unexpected` field correctly identifies the unrecognized arg, but automation that checks `exit == 0` or `action != "error"` will treat this as a successful invocation. This is distinct from ROADMAP #108 which covers *unrecognized CLI subcommands* falling through to the LLM prompt path — #419 targets MCP-specific *known-but-unimplemented* subcommands that return `action:"help"` with exit 0 instead of an explicit `action:"error"` envelope. **Required fix shape:** (a) return a non-zero exit code (exit 1 or exit 2) when an unrecognized or unimplemented MCP subcommand is provided; (b) emit `action:"error"` (or `kind:"error"`) with a `code:"unknown_subcommand"` and `unknown:"add"` field instead of `action:"help"`; (c) optionally include the help/usage payload as a sibling field `suggestion:{usage:{...}}` for context; (d) add regression coverage proving `mcp --output-format json` returns a non-zero exit code and a non-help action token. **Why this matters:** `add` and `remove` are common MCP lifecycle operations that users will attempt; returning `action:"help"` with exit 0 makes these look like successful no-ops to any automation that doesn't deep-inspect the `unexpected` field. A pipeline that runs `claw mcp add my-server ... && claw mcp show my-server` will silently proceed to the show step even though add silently no-oped. Source: Jobdori live dogfood, `e939777f`, 2026-05-01. + + +420. **`plugins help --output-format json` returns the mutation response shape (`message`, `reload_runtime`, `target`) instead of the help envelope (`action:"help"`, `kind`, `unexpected`, `usage`) that `mcp help`, `agents help`, and `skills help` all use — schema drift within the same command family** — dogfooded 2026-05-01 by Jobdori on `e939777f`. Running `claw plugins help --output-format json` returns `{"action":"help","kind":"plugin","message":"Unknown /plugins action 'help'. Use list, install, enable, disable, uninstall, or update.","reload_runtime":false,"target":null}`. By contrast, `claw mcp help --output-format json`, `claw agents help --output-format json`, and `claw skills help --output-format json` all return a help envelope: `{"action":"help","kind":"","unexpected":null,"usage":{"direct_cli":"...","slash_command":"...","sources":[...]}}`. The `plugins` subgroup has not adopted the help envelope schema used by all sibling subgroups. Instead it uses the mutation response shape (`message`, `reload_runtime`, `target`) with an error string in `message` that calls `help` an "unknown action." Automation that checks `usage.direct_cli` to discover plugin commands gets a `TypeError` (key not found) on the plugins help path while succeeding on all sibling subgroups. **Required fix shape:** (a) make `plugins help` return the same help envelope as `mcp help`/`agents help`/`skills help`: `{action:"help", kind:"plugin", unexpected:null, usage:{direct_cli:"claw plugins [list|enable|disable|install|uninstall|update|help]", slash_command:"/plugins [...]", sources:[...]}`; (b) drop `reload_runtime` and `target` from help responses for all plugin subcommands; (c) add regression coverage proving `plugins help --output-format json` contains a `usage.direct_cli` field matching the same envelope shape as `mcp help`/`agents help`/`skills help`; (d) audit all subgroup `help` handlers for the same mutation-envelope contamination. **Why this matters:** help discovery is the bootstrap surface for automation. If `plugins help --output-format json` returns a mutation envelope with an error message instead of a usage envelope, automated schema discovery fails silently for the entire plugins subgroup while working for every other subgroup. Source: Jobdori live dogfood, `e939777f`, 2026-05-01. + From 75c08bc982c927d6d986edb552d7349d55e3b59e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 6 May 2026 15:32:34 +0900 Subject: [PATCH 17/18] fix: REPL display, /compact panic, identity leak, DeepSeek reasoning, thinking blocks Five interrelated fixes from parallel Hephaestus sessions: 1. fix(repl): display assistant text after spinner (#2981, #2982, #2937) - Added final_assistant_text() call after run_turn spinner completes - REPL now shows response text like run_prompt_json does 2. fix(compact): handle Thinking content blocks (#2985) - Added ContentBlock::Thinking variant throughout compact summarizer - Prevents panic when /compact encounters thinking blocks 3. fix(prompt): provider-aware model identity (#2822) - New ModelFamilyIdentity enum (Claude vs Generic) - Non-Anthropic models no longer say 'I am Claude' - model_family_identity_for() detects provider and sets identity 4. fix(openai): preserve DeepSeek reasoning_content (#2821) - Stream parser now captures reasoning_content from OpenAI-compat - Emits ThinkingDelta/SignatureDelta events for reasoning models - Thinking blocks included in conversation history for re-send 5. feat(runtime): Thinking block support across codebase - AssistantEvent::Thinking variant in conversation.rs - ContentBlock::Thinking in session serialization - Thinking-aware compact summarization - Tests for thinking block ordering and content Closes #2981, #2982, #2937, #2985, #2822, #2821 --- rust/crates/api/src/lib.rs | 5 +- rust/crates/api/src/providers/mod.rs | 53 ++- .../crates/api/src/providers/openai_compat.rs | 338 ++++++++++++++++-- rust/crates/api/src/types.rs | 37 +- .../api/tests/openai_compat_integration.rs | 44 +++ rust/crates/mock-anthropic-service/src/lib.rs | 1 + rust/crates/runtime/src/compact.rs | 10 + rust/crates/runtime/src/conversation.rs | 55 +++ rust/crates/runtime/src/lib.rs | 4 +- rust/crates/runtime/src/prompt.rs | 93 ++++- rust/crates/runtime/src/session.rs | 57 +++ rust/crates/rusty-claude-cli/src/main.rs | 86 ++++- .../rusty-claude-cli/tests/compact_output.rs | 78 ++++ .../tests/compact_repl_panic.rs | 138 +++++++ rust/crates/tools/src/lib.rs | 175 +++++++-- 15 files changed, 1099 insertions(+), 75 deletions(-) create mode 100644 rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index 40da29f1..d55b211b 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -21,11 +21,12 @@ pub use prompt_cache::{ pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource}; pub use providers::openai_compat::{ build_chat_completion_request, flatten_tool_result_content, is_reasoning_model, - model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig, + model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message, + OpenAiCompatClient, OpenAiCompatConfig, }; pub use providers::{ detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override, - resolve_model_alias, ProviderKind, + model_family_identity_for, model_family_identity_for_kind, resolve_model_alias, ProviderKind, }; pub use sse::{parse_frame, SseParser}; pub use types::{ diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 0ef663f1..9c50eb7a 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -250,6 +250,19 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind { ProviderKind::Anthropic } +#[must_use] +pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity { + match kind { + ProviderKind::Anthropic => runtime::ModelFamilyIdentity::Claude, + ProviderKind::Xai | ProviderKind::OpenAi => runtime::ModelFamilyIdentity::Generic, + } +} + +#[must_use] +pub fn model_family_identity_for(model: &str) -> runtime::ModelFamilyIdentity { + model_family_identity_for_kind(detect_provider_kind(model)) +} + #[must_use] pub fn max_tokens_for_model(model: &str) -> u32 { let canonical = resolve_model_alias(model); @@ -484,8 +497,8 @@ mod tests { use super::{ anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind, load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override, - model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias, - ProviderKind, + model_family_identity_for, model_family_identity_for_kind, model_token_limit, parse_dotenv, + preflight_message_request, resolve_model_alias, ProviderKind, }; /// Serializes every test in this module that mutates process-wide @@ -544,6 +557,42 @@ mod tests { ); } + #[test] + fn maps_provider_kind_to_model_family_identity() { + // given: each supported provider kind + let anthropic = ProviderKind::Anthropic; + let openai = ProviderKind::OpenAi; + let xai = ProviderKind::Xai; + + // when: converting provider kinds to prompt model family identities + let anthropic_identity = model_family_identity_for_kind(anthropic); + let openai_identity = model_family_identity_for_kind(openai); + let xai_identity = model_family_identity_for_kind(xai); + + // then: Anthropic stays Claude and OpenAI-compatible providers are generic + assert_eq!(anthropic_identity, runtime::ModelFamilyIdentity::Claude); + assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic); + assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic); + } + + #[test] + fn maps_model_name_to_model_family_identity() { + // given: Anthropic, OpenAI-compatible, and xAI model names + let claude_model = "claude-opus-4-6"; + let openai_model = "openai/gpt-4.1-mini"; + let xai_model = "grok-3"; + + // when: detecting prompt model family identities from model names + let claude_identity = model_family_identity_for(claude_model); + let openai_identity = model_family_identity_for(openai_model); + let xai_identity = model_family_identity_for(xai_model); + + // then: Anthropic stays Claude and OpenAI-compatible providers are generic + assert_eq!(claude_identity, runtime::ModelFamilyIdentity::Claude); + assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic); + assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic); + } + #[test] fn openai_namespaced_model_routes_to_openai_not_anthropic() { // Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a810502e..b3800d6a 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -443,6 +443,8 @@ struct StreamState { stop_reason: Option, usage: Option, tool_calls: BTreeMap, + thinking_started: bool, + thinking_finished: bool, } impl StreamState { @@ -456,6 +458,8 @@ impl StreamState { stop_reason: None, usage: None, tool_calls: BTreeMap::new(), + thinking_started: false, + thinking_finished: false, } } @@ -493,35 +497,61 @@ impl StreamState { } for choice in chunk.choices { + if let Some(reasoning) = choice + .delta + .reasoning_content + .filter(|value| !value.is_empty()) + { + if !self.thinking_started { + self.thinking_started = true; + events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent { + index: 0, + content_block: OutputContentBlock::Thinking { + thinking: String::new(), + signature: None, + }, + })); + } + events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { + index: 0, + delta: ContentBlockDelta::ThinkingDelta { + thinking: reasoning, + }, + })); + } + if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) { + self.close_thinking(&mut events); if !self.text_started { self.text_started = true; events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent { - index: 0, + index: self.text_block_index(), content_block: OutputContentBlock::Text { text: String::new(), }, })); } events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { - index: 0, + index: self.text_block_index(), delta: ContentBlockDelta::TextDelta { text: content }, })); } for tool_call in choice.delta.tool_calls { + self.close_thinking(&mut events); + let tool_index_offset = self.tool_index_offset(); let state = self.tool_calls.entry(tool_call.index).or_default(); state.apply(tool_call); - let block_index = state.block_index(); + let block_index = state.block_index(tool_index_offset); if !state.started { - if let Some(start_event) = state.start_event()? { + if let Some(start_event) = state.start_event(tool_index_offset)? { state.started = true; events.push(StreamEvent::ContentBlockStart(start_event)); } else { continue; } } - if let Some(delta_event) = state.delta_event() { + if let Some(delta_event) = state.delta_event(tool_index_offset) { events.push(StreamEvent::ContentBlockDelta(delta_event)); } if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped { @@ -535,11 +565,12 @@ impl StreamState { if let Some(finish_reason) = choice.finish_reason { self.stop_reason = Some(normalize_finish_reason(&finish_reason)); if finish_reason == "tool_calls" { + let tool_index_offset = self.tool_index_offset(); for state in self.tool_calls.values_mut() { if state.started && !state.stopped { state.stopped = true; events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent { - index: state.block_index(), + index: state.block_index(tool_index_offset), })); } } @@ -557,19 +588,21 @@ impl StreamState { self.finished = true; let mut events = Vec::new(); + self.close_thinking(&mut events); if self.text_started && !self.text_finished { self.text_finished = true; events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent { - index: 0, + index: self.text_block_index(), })); } + let tool_index_offset = self.tool_index_offset(); for state in self.tool_calls.values_mut() { if !state.started { - if let Some(start_event) = state.start_event()? { + if let Some(start_event) = state.start_event(tool_index_offset)? { state.started = true; events.push(StreamEvent::ContentBlockStart(start_event)); - if let Some(delta_event) = state.delta_event() { + if let Some(delta_event) = state.delta_event(tool_index_offset) { events.push(StreamEvent::ContentBlockDelta(delta_event)); } } @@ -577,7 +610,7 @@ impl StreamState { if state.started && !state.stopped { state.stopped = true; events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent { - index: state.block_index(), + index: state.block_index(tool_index_offset), })); } } @@ -603,6 +636,31 @@ impl StreamState { } Ok(events) } + + fn close_thinking(&mut self, events: &mut Vec) { + if self.thinking_started && !self.thinking_finished { + self.thinking_finished = true; + events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent { + index: 0, + })); + } + } + + const fn text_block_index(&self) -> u32 { + if self.thinking_started { + 1 + } else { + 0 + } + } + + const fn tool_index_offset(&self) -> u32 { + if self.thinking_started { + 2 + } else { + 1 + } + } } #[derive(Debug, Default)] @@ -630,12 +688,12 @@ impl ToolCallState { } } - const fn block_index(&self) -> u32 { - self.openai_index + 1 + const fn block_index(&self, offset: u32) -> u32 { + self.openai_index + offset } #[allow(clippy::unnecessary_wraps)] - fn start_event(&self) -> Result, ApiError> { + fn start_event(&self, offset: u32) -> Result, ApiError> { let Some(name) = self.name.clone() else { return Ok(None); }; @@ -644,7 +702,7 @@ impl ToolCallState { .clone() .unwrap_or_else(|| format!("tool_call_{}", self.openai_index)); Ok(Some(ContentBlockStartEvent { - index: self.block_index(), + index: self.block_index(offset), content_block: OutputContentBlock::ToolUse { id, name, @@ -653,14 +711,14 @@ impl ToolCallState { })) } - fn delta_event(&mut self) -> Option { + fn delta_event(&mut self, offset: u32) -> Option { if self.emitted_len >= self.arguments.len() { return None; } let delta = self.arguments[self.emitted_len..].to_string(); self.emitted_len = self.arguments.len(); Some(ContentBlockDeltaEvent { - index: self.block_index(), + index: self.block_index(offset), delta: ContentBlockDelta::InputJsonDelta { partial_json: delta, }, @@ -690,6 +748,8 @@ struct ChatMessage { #[serde(default)] content: Option, #[serde(default)] + reasoning_content: Option, + #[serde(default)] tool_calls: Vec, } @@ -735,6 +795,8 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + #[serde(default)] + reasoning_content: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } @@ -793,6 +855,15 @@ pub fn is_reasoning_model(model: &str) -> bool { || canonical.contains("thinking") } +/// Returns true for OpenAI-compatible DeepSeek V4 models that require prior +/// assistant reasoning to be echoed back as `reasoning_content` in history. +#[must_use] +pub fn model_requires_reasoning_content_in_history(model: &str) -> bool { + let lowered = model.to_ascii_lowercase(); + let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str()); + canonical.starts_with("deepseek-v4") +} + /// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire. /// The prefix is used only to select transport; the backend expects the /// bare model id. @@ -948,10 +1019,14 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec { match message.role.as_str() { "assistant" => { let mut text = String::new(); + let mut reasoning = String::new(); let mut tool_calls = Vec::new(); for block in &message.content { match block { InputContentBlock::Text { text: value } => text.push_str(value), + InputContentBlock::Thinking { + thinking: value, .. + } => reasoning.push_str(value), InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({ "id": id, "type": "function", @@ -963,13 +1038,18 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec { InputContentBlock::ToolResult { .. } => {} } } - if text.is_empty() && tool_calls.is_empty() { + let include_reasoning = + model_requires_reasoning_content_in_history(model) && !reasoning.is_empty(); + if text.is_empty() && tool_calls.is_empty() && !include_reasoning { Vec::new() } else { let mut msg = serde_json::json!({ "role": "assistant", "content": (!text.is_empty()).then_some(text), }); + if include_reasoning { + msg["reasoning_content"] = json!(reasoning); + } // Only include tool_calls when non-empty: some providers reject // assistant messages with an explicit empty tool_calls array. if !tool_calls.is_empty() { @@ -1003,6 +1083,7 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec { } Some(msg) } + InputContentBlock::Thinking { .. } => None, InputContentBlock::ToolUse { .. } => None, }) .collect(), @@ -1182,6 +1263,16 @@ fn normalize_response( "chat completion response missing choices", ))?; let mut content = Vec::new(); + if let Some(thinking) = choice + .message + .reasoning_content + .filter(|value| !value.is_empty()) + { + content.push(OutputContentBlock::Thinking { + thinking, + signature: None, + }); + } if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) { content.push(OutputContentBlock::Text { text }); } @@ -1413,13 +1504,15 @@ impl StringExt for String { mod tests { use super::{ build_chat_completion_request, chat_completions_endpoint, is_reasoning_model, - normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, - OpenAiCompatConfig, + model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response, + openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig, + StreamState, }; use crate::error::ApiError; use crate::types::{ - InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition, - ToolResultContentBlock, + ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, + InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent, + ToolChoice, ToolDefinition, ToolResultContentBlock, }; use serde_json::json; use std::sync::{Mutex, OnceLock}; @@ -1465,6 +1558,188 @@ mod tests { assert_eq!(payload["tool_choice"], json!("auto")); } + #[test] + fn model_requires_reasoning_content_in_history_detects_deepseek_v4_models() { + // Given DeepSeek V4 and non-V4 model names. + let positive = [ + "deepseek-v4-flash", + "deepseek-v4-pro", + "openai/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + ]; + let negative = [ + "deepseek-reasoner", + "deepseek-chat", + "gpt-4o", + "claude-sonnet-4-6", + ]; + + // When checking whether history reasoning_content is required. + // Then only DeepSeek V4 variants require it. + for model in positive { + assert!(model_requires_reasoning_content_in_history(model)); + } + for model in negative { + assert!(!model_requires_reasoning_content_in_history(model)); + } + } + + #[test] + fn legacy_deepseek_reasoner_request_omits_reasoning_content_for_assistant_history() { + // Given an assistant history turn containing thinking. + let request = assistant_history_with_thinking_request("deepseek-reasoner"); + + // When serializing for legacy deepseek-reasoner. + let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai()); + + // Then reasoning_content is omitted. + let assistant = &payload["messages"][0]; + assert_eq!(assistant["role"], json!("assistant")); + assert!(assistant.get("reasoning_content").is_none()); + } + + #[test] + fn deepseek_v4_pro_request_includes_reasoning_content_for_assistant_history() { + // Given an assistant history turn containing thinking. + let request = assistant_history_with_thinking_request("openai/deepseek-v4-pro"); + + // When serializing for DeepSeek V4 Pro. + let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai()); + + // Then reasoning_content is included on the assistant message. + let assistant = &payload["messages"][0]; + assert_eq!(assistant["reasoning_content"], json!("prior reasoning")); + assert_eq!(assistant["content"], json!("answer")); + } + + #[test] + fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() { + // Given an assistant history turn containing thinking. + let request = assistant_history_with_thinking_request("deepseek-v4-flash"); + + // When serializing for DeepSeek V4 Flash. + let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai()); + + // Then reasoning_content is included on the assistant message. + let assistant = &payload["messages"][0]; + assert_eq!(assistant["reasoning_content"], json!("prior reasoning")); + } + + #[test] + fn non_streaming_response_with_reasoning_content_emits_thinking_block_first() { + // Given a non-streaming OpenAI-compatible response with reasoning_content. + let response = super::ChatCompletionResponse { + id: "chatcmpl_reasoning".to_string(), + model: "deepseek-v4-pro".to_string(), + choices: vec![super::ChatChoice { + message: super::ChatMessage { + role: "assistant".to_string(), + content: Some("final answer".to_string()), + reasoning_content: Some("hidden thought".to_string()), + tool_calls: Vec::new(), + }, + finish_reason: Some("stop".to_string()), + }], + usage: None, + }; + + // When normalizing the provider response. + let normalized = normalize_response("deepseek-v4-pro", response).expect("normalized"); + + // Then Thinking is the first content block, before text. + assert_eq!( + normalized.content, + vec![ + OutputContentBlock::Thinking { + thinking: "hidden thought".to_string(), + signature: None, + }, + OutputContentBlock::Text { + text: "final answer".to_string(), + }, + ] + ); + } + + #[test] + fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() { + // Given streaming chunks with reasoning_content followed by text. + let mut state = StreamState::new("deepseek-v4-pro".to_string()); + let mut events = state + .ingest_chunk(super::ChatCompletionChunk { + id: "chatcmpl_stream_reasoning".to_string(), + model: Some("deepseek-v4-pro".to_string()), + choices: vec![super::ChunkChoice { + delta: super::ChunkDelta { + content: None, + reasoning_content: Some("think".to_string()), + tool_calls: Vec::new(), + }, + finish_reason: None, + }], + usage: None, + }) + .expect("reasoning chunk"); + events.extend( + state + .ingest_chunk(super::ChatCompletionChunk { + id: "chatcmpl_stream_reasoning".to_string(), + model: None, + choices: vec![super::ChunkChoice { + delta: super::ChunkDelta { + content: Some(" answer".to_string()), + reasoning_content: None, + tool_calls: Vec::new(), + }, + finish_reason: Some("stop".to_string()), + }], + usage: None, + }) + .expect("text chunk"), + ); + events.extend(state.finish().expect("finish")); + + // When reading normalized stream events. + // Then Thinking starts at index 0, text is offset to index 1. + assert!(matches!(events[0], StreamEvent::MessageStart(_))); + assert!(matches!( + events[1], + StreamEvent::ContentBlockStart(ContentBlockStartEvent { + index: 0, + content_block: OutputContentBlock::Thinking { .. }, + }) + )); + assert!(matches!( + events[2], + StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { + index: 0, + delta: ContentBlockDelta::ThinkingDelta { .. }, + }) + )); + assert!(matches!( + events[3], + StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 }) + )); + assert!(matches!( + events[4], + StreamEvent::ContentBlockStart(ContentBlockStartEvent { + index: 1, + content_block: OutputContentBlock::Text { .. }, + }) + )); + assert!(matches!( + events[5], + StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { + index: 1, + delta: ContentBlockDelta::TextDelta { .. }, + }) + )); + assert!(matches!( + events[6], + StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 }) + )); + } + #[test] fn tool_schema_object_gets_strict_fields_for_responses_endpoint() { // OpenAI /responses endpoint rejects object schemas missing @@ -1624,6 +1899,27 @@ mod tests { ); } + fn assistant_history_with_thinking_request(model: &str) -> MessageRequest { + MessageRequest { + model: model.to_string(), + max_tokens: 100, + messages: vec![InputMessage { + role: "assistant".to_string(), + content: vec![ + InputContentBlock::Thinking { + thinking: "prior reasoning".to_string(), + signature: None, + }, + InputContentBlock::Text { + text: "answer".to_string(), + }, + ], + }], + stream: false, + ..Default::default() + } + } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs index e136a766..0d41db19 100644 --- a/rust/crates/api/src/types.rs +++ b/rust/crates/api/src/types.rs @@ -81,6 +81,11 @@ pub enum InputContentBlock { Text { text: String, }, + Thinking { + thinking: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + }, ToolUse { id: String, name: String, @@ -268,8 +273,9 @@ pub enum StreamEvent { #[cfg(test)] mod tests { use runtime::format_usd; + use serde_json::json; - use super::{MessageResponse, Usage}; + use super::{InputContentBlock, MessageResponse, Usage}; #[test] fn usage_total_tokens_includes_cache_tokens() { @@ -307,4 +313,33 @@ mod tests { assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750"); assert_eq!(response.total_tokens(), 1_800_000); } + + #[test] + fn input_content_block_thinking_serializes_with_snake_case_type() { + // given + let block = InputContentBlock::Thinking { + thinking: "pondering".to_string(), + signature: Some("sig_123".to_string()), + }; + + // when + let serialized = serde_json::to_value(&block).unwrap(); + let deserialized: InputContentBlock = serde_json::from_value(json!({ + "type": "thinking", + "thinking": "pondering", + "signature": "sig_123" + })) + .unwrap(); + + // then + assert_eq!( + serialized, + json!({ + "type": "thinking", + "thinking": "pondering", + "signature": "sig_123" + }) + ); + assert_eq!(deserialized, block); + } } diff --git a/rust/crates/api/tests/openai_compat_integration.rs b/rust/crates/api/tests/openai_compat_integration.rs index d5596bb0..5db9eaf1 100644 --- a/rust/crates/api/tests/openai_compat_integration.rs +++ b/rust/crates/api/tests/openai_compat_integration.rs @@ -63,6 +63,50 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() { assert_eq!(body["tools"][0]["type"], json!("function")); } +#[tokio::test] +async fn send_message_preserves_deepseek_reasoning_content_before_text() { + let state = Arc::new(Mutex::new(Vec::::new())); + let body = concat!( + "{", + "\"id\":\"chatcmpl_deepseek_reasoning\",", + "\"model\":\"deepseek-v4-pro\",", + "\"choices\":[{", + "\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},", + "\"finish_reason\":\"stop\"", + "}],", + "\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}", + "}" + ); + let server = spawn_server( + state.clone(), + vec![http_response("200 OK", "application/json", body)], + ) + .await; + + let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai()) + .with_base_url(server.base_url()); + let response = client + .send_message(&MessageRequest { + model: "openai/deepseek-v4-pro".to_string(), + ..sample_request(false) + }) + .await + .expect("request should succeed"); + + assert_eq!( + response.content, + vec![ + OutputContentBlock::Thinking { + thinking: "Think first".to_string(), + signature: None, + }, + OutputContentBlock::Text { + text: "Answer second".to_string(), + }, + ] + ); +} + #[tokio::test] async fn send_message_blocks_oversized_xai_requests_before_the_http_call() { let state = Arc::new(Mutex::new(Vec::::new())); diff --git a/rust/crates/mock-anthropic-service/src/lib.rs b/rust/crates/mock-anthropic-service/src/lib.rs index 68968eed..99623d18 100644 --- a/rust/crates/mock-anthropic-service/src/lib.rs +++ b/rust/crates/mock-anthropic-service/src/lib.rs @@ -248,6 +248,7 @@ fn detect_scenario(request: &MessageRequest) -> Option { .split_whitespace() .find_map(|token| token.strip_prefix(SCENARIO_PREFIX)) .and_then(Scenario::parse), + InputContentBlock::Thinking { .. } => None, _ => None, }) }) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 3e805dda..e4fd3db0 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -213,6 +213,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { ContentBlock::ToolUse { name, .. } => Some(name.as_str()), ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()), ContentBlock::Text { .. } => None, + ContentBlock::Thinking { .. } => None, }) .collect::>(); tool_names.sort_unstable(); @@ -317,6 +318,9 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), + ContentBlock::Thinking { thinking, .. } => { + format!("thinking ({} chars)", thinking.chars().count()) + } ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolResult { tool_name, @@ -378,6 +382,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec { ContentBlock::Text { text } => text.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(), + ContentBlock::Thinking { thinking, .. } => thinking.as_str(), }) .flat_map(extract_file_candidates) .collect::>(); @@ -400,6 +405,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> { ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()), ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } + | ContentBlock::Thinking { .. } | ContentBlock::Text { .. } => None, }) } @@ -450,6 +456,10 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize { ContentBlock::ToolResult { tool_name, output, .. } => (tool_name.len() + output.len()) / 4 + 1, + ContentBlock::Thinking { + thinking, + signature, + } => thinking.len() / 4 + signature.as_ref().map_or(0, |value| value.len() / 4 + 1), }) .sum() } diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 610ba1a8..35c0f73f 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -28,6 +28,10 @@ pub struct ApiRequest { /// Streamed events emitted while processing a single assistant turn. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AssistantEvent { + Thinking { + thinking: String, + signature: Option, + }, TextDelta(String), ToolUse { id: String, @@ -721,6 +725,16 @@ fn build_assistant_message( for event in events { match event { + AssistantEvent::Thinking { + thinking, + signature, + } => { + flush_text_block(&mut text, &mut blocks); + blocks.push(ContentBlock::Thinking { + thinking, + signature, + }); + } AssistantEvent::TextDelta(delta) => text.push_str(&delta), AssistantEvent::ToolUse { id, name, input } => { flush_text_block(&mut text, &mut blocks); @@ -1723,6 +1737,47 @@ mod tests { .contains("assistant stream produced no content")); } + #[test] + fn build_assistant_message_places_thinking_block_before_text_and_tool_use() { + // given + let events = vec![ + AssistantEvent::Thinking { + thinking: "pondering".to_string(), + signature: Some("sig".to_string()), + }, + AssistantEvent::TextDelta("hello".to_string()), + AssistantEvent::ToolUse { + id: "tool-1".to_string(), + name: "echo".to_string(), + input: "payload".to_string(), + }, + AssistantEvent::MessageStop, + ]; + + // when + let (message, _, _) = build_assistant_message(events) + .expect("assistant message should preserve thinking, text, and tool blocks"); + + // then + assert_eq!( + message.blocks, + vec![ + ContentBlock::Thinking { + thinking: "pondering".to_string(), + signature: Some("sig".to_string()), + }, + ContentBlock::Text { + text: "hello".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "echo".to_string(), + input: "payload".to_string(), + }, + ] + ); + } + #[test] fn static_tool_executor_rejects_unknown_tools() { // given diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c7d87091..c1108d3d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -131,8 +131,8 @@ pub use policy_engine::{ PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, }; pub use prompt::{ - load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError, - SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext, + PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; pub use recovery_recipes::{ attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext, diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index e46b7ebe..1e6c4eda 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -43,6 +43,24 @@ pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6"; const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000; const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000; +/// Neutral identity for the model family line in generated prompts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ModelFamilyIdentity { + #[default] + Claude, + Generic, +} + +impl ModelFamilyIdentity { + #[must_use] + pub const fn family_label(self) -> &'static str { + match self { + Self::Claude => FRONTIER_MODEL_NAME, + Self::Generic => "an AI assistant", + } + } +} + /// Contents of an instruction file included in prompt construction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextFile { @@ -97,6 +115,7 @@ pub struct SystemPromptBuilder { output_style_prompt: Option, os_name: Option, os_version: Option, + model_family: Option, append_sections: Vec, project_context: Option, config: Option, @@ -122,6 +141,12 @@ impl SystemPromptBuilder { self } + #[must_use] + pub fn with_model_family(mut self, model_family: ModelFamilyIdentity) -> Self { + self.model_family = Some(model_family); + self + } + #[must_use] pub fn with_project_context(mut self, project_context: ProjectContext) -> Self { self.project_context = Some(project_context); @@ -179,9 +204,10 @@ impl SystemPromptBuilder { || "unknown".to_string(), |context| context.current_date.clone(), ); + let identity = self.model_family.unwrap_or_default(); let mut lines = vec!["# Environment context".to_string()]; lines.extend(prepend_bullets(vec![ - format!("Model family: {FRONTIER_MODEL_NAME}"), + format!("Model family: {}", identity.family_label()), format!("Working directory: {cwd}"), format!("Date: {date}"), format!( @@ -434,12 +460,14 @@ pub fn load_system_prompt( current_date: impl Into, os_name: impl Into, os_version: impl Into, + model_family: ModelFamilyIdentity, ) -> Result, PromptBuildError> { let cwd = cwd.into(); let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?; let config = ConfigLoader::default_for(&cwd).load()?; Ok(SystemPromptBuilder::new() .with_os(os_name, os_version) + .with_model_family(model_family) .with_project_context(project_context) .with_runtime_config(config) .build()) @@ -522,7 +550,8 @@ mod tests { use super::{ collapse_blank_lines, display_context_path, normalize_instruction_content, render_instruction_content, render_instruction_files, truncate_instruction_content, - ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder, + SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; use crate::config::ConfigLoader; use std::fs; @@ -804,13 +833,19 @@ mod tests { std::env::set_var("HOME", &root); std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home")); std::env::set_current_dir(&root).expect("change cwd"); - let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8") - .expect("system prompt should load") - .join( - " + let prompt = super::load_system_prompt( + &root, + "2026-03-31", + "linux", + "6.8", + ModelFamilyIdentity::Claude, + ) + .expect("system prompt should load") + .join( + " ", - ); + ); std::env::set_current_dir(previous).expect("restore cwd"); if let Some(value) = original_home { std::env::set_var("HOME", value); @@ -828,6 +863,50 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn renders_default_claude_model_family_identity() { + // given: a prompt builder without an explicit model family override + let project_context = ProjectContext { + cwd: PathBuf::from("/tmp/project"), + current_date: "2026-03-31".to_string(), + ..ProjectContext::default() + }; + + // when: rendering the system prompt environment section + let prompt = SystemPromptBuilder::new() + .with_os("linux", "6.8") + .with_project_context(project_context) + .render(); + + // then: the Claude model family label is preserved by default + assert!(prompt.contains("Model family: Claude Opus 4.6")); + } + + #[test] + fn renders_generic_model_family_identity_without_claude_label() { + // given: a prompt builder with generic model family identity + let project_context = ProjectContext { + cwd: PathBuf::from("/tmp/project"), + current_date: "2026-03-31".to_string(), + ..ProjectContext::default() + }; + + // when: rendering the system prompt environment section + let prompt = SystemPromptBuilder::new() + .with_os("linux", "6.8") + .with_model_family(ModelFamilyIdentity::Generic) + .with_project_context(project_context) + .render(); + let model_family_line = prompt + .lines() + .find(|line| line.contains("Model family:")) + .expect("model family line should render"); + + // then: the model family line is neutral and excludes Claude Opus 4.6 + assert_eq!(model_family_line, " - Model family: an AI assistant"); + assert!(!model_family_line.contains("Claude Opus 4.6")); + } + #[test] fn renders_claude_code_style_sections_with_project_context() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index b97378e5..6b62444d 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -30,6 +30,10 @@ pub enum ContentBlock { Text { text: String, }, + Thinking { + thinking: String, + signature: Option, + }, ToolUse { id: String, name: String, @@ -737,6 +741,22 @@ impl ContentBlock { object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("text".to_string(), JsonValue::String(text.clone())); } + Self::Thinking { + thinking, + signature, + } => { + object.insert( + "type".to_string(), + JsonValue::String("thinking".to_string()), + ); + object.insert("thinking".to_string(), JsonValue::String(thinking.clone())); + if let Some(signature) = signature { + object.insert( + "signature".to_string(), + JsonValue::String(signature.clone()), + ); + } + } Self::ToolUse { id, name, input } => { object.insert( "type".to_string(), @@ -783,6 +803,13 @@ impl ContentBlock { "text" => Ok(Self::Text { text: required_string(object, "text")?, }), + "thinking" => Ok(Self::Thinking { + thinking: required_string(object, "thinking")?, + signature: object + .get("signature") + .and_then(JsonValue::as_str) + .map(String::from), + }), "tool_use" => Ok(Self::ToolUse { id: required_string(object, "id")?, name: required_string(object, "name")?, @@ -1208,6 +1235,36 @@ mod tests { assert_eq!(restored.session_id, session.session_id); } + #[test] + fn persists_assistant_thinking_block_round_trip_through_jsonl() { + // given + let mut session = Session::new(); + session + .push_message(ConversationMessage::assistant(vec![ + ContentBlock::Thinking { + thinking: "trace the path through session persistence".to_string(), + signature: Some("sig-123".to_string()), + }, + ])) + .expect("thinking block should append"); + let path = temp_session_path("thinking-jsonl"); + + // when + session.save_to_path(&path).expect("session should save"); + let restored = Session::load_from_path(&path).expect("session should load"); + fs::remove_file(&path).expect("temp file should be removable"); + + // then + assert_eq!(restored, session); + assert_eq!( + restored.messages[0].blocks[0], + ContentBlock::Thinking { + thinking: "trace the path through session persistence".to_string(), + signature: Some("sig-123".to_string()), + } + ); + } + #[test] fn loads_legacy_session_json_object() { let path = temp_session_path("legacy"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 628118af..df4d8da4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -24,10 +24,11 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ - detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, - ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, - OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, + detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient, + AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, + MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, + ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, + ToolResultContentBlock, }; use commands::{ @@ -357,8 +358,9 @@ fn run() -> Result<(), Box> { CliAction::PrintSystemPrompt { cwd, date, + model, output_format, - } => print_system_prompt(cwd, date, output_format)?, + } => print_system_prompt(cwd, date, &model, output_format)?, CliAction::Version { output_format } => print_version(output_format)?, CliAction::ResumeSession { session_path, @@ -498,6 +500,7 @@ enum CliAction { PrintSystemPrompt { cwd: PathBuf, date: String, + model: String, output_format: CliOutputFormat, }, Version { @@ -960,7 +963,7 @@ fn parse_args(args: &[String]) -> Result { }), } } - "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), + "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), "acp" => parse_acp_args(&rest[1..], output_format), "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => Ok(CliAction::Init { output_format }), @@ -1638,6 +1641,7 @@ fn filter_tool_specs( fn parse_system_prompt_args( args: &[String], + model: String, output_format: CliOutputFormat, ) -> Result { let mut cwd = env::current_dir().map_err(|error| error.to_string())?; @@ -1674,6 +1678,7 @@ fn parse_system_prompt_args( Ok(CliAction::PrintSystemPrompt { cwd, date, + model, output_format, }) } @@ -2614,9 +2619,16 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box Result<(), Box> { - let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; + let sections = load_system_prompt( + cwd, + date, + env::consts::OS, + "unknown", + model_family_identity_for(model), + )?; let message = sections.join( " @@ -4394,7 +4406,7 @@ impl LiveCli { allowed_tools: Option, permission_mode: PermissionMode, ) -> Result> { - let system_prompt = build_system_prompt()?; + let system_prompt = build_system_prompt(&model)?; let session_state = new_cli_session()?; let session = create_managed_session_handle(&session_state.session_id)?; let runtime = build_runtime( @@ -4530,6 +4542,10 @@ impl LiveCli { TerminalRenderer::new().color_theme(), &mut stdout, )?; + let final_text = final_assistant_text(&summary); + if !final_text.is_empty() { + println!("{final_text}"); + } println!(); if let Some(event) = summary.auto_compaction { println!( @@ -7005,6 +7021,7 @@ fn render_export_text(session: &Session) -> String { for block in &message.blocks { match block { ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::Thinking { .. } => {} ContentBlock::ToolUse { id, name, input } => { lines.push(format!("[tool_use id={id} name={name}] {input}")); } @@ -7191,6 +7208,7 @@ fn render_session_markdown(session: &Session, session_id: &str, session_path: &P lines.push(String::new()); } } + ContentBlock::Thinking { .. } => {} ContentBlock::ToolUse { id, name, input } => { lines.push(format!( "**Tool call** `{name}` _(id `{}`)_", @@ -7244,12 +7262,13 @@ fn short_tool_id(id: &str) -> String { format!("{prefix}…") } -fn build_system_prompt() -> Result, Box> { +fn build_system_prompt(model: &str) -> Result, Box> { Ok(load_system_prompt( env::current_dir()?, DEFAULT_DATE, env::consts::OS, "unknown", + model_family_identity_for(model), )?) } @@ -9211,26 +9230,29 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { let content = message .blocks .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + .filter_map(|block| match block { + ContentBlock::Text { text } => { + Some(InputContentBlock::Text { text: text.clone() }) + } + ContentBlock::Thinking { .. } => None, + ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }, + }), ContentBlock::ToolResult { tool_use_id, output, is_error, .. - } => InputContentBlock::ToolResult { + } => Some(InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, - }, + }), }) .collect::>(); (!content.is_empty()).then(|| InputMessage { @@ -9628,7 +9650,9 @@ mod tests { "{rendered}" ); assert!( - rendered.contains("Detail Input tokens exceed the configured limit of 922000 tokens."), + rendered.contains( + "Detail Input tokens exceed the configured limit of 922000 tokens." + ), "{rendered}" ); assert!(rendered.contains("Compact /compact"), "{rendered}"); @@ -10264,6 +10288,7 @@ mod tests { #[test] fn parses_system_prompt_options() { + // given: system-prompt options for cwd and date let args = vec![ "system-prompt".to_string(), "--cwd".to_string(), @@ -10271,16 +10296,43 @@ mod tests { "--date".to_string(), "2026-04-01".to_string(), ]; + + // when: parsing the direct system-prompt command + let action = parse_args(&args).expect("args should parse"); + + // then: the action carries prompt options and default model assert_eq!( - parse_args(&args).expect("args should parse"), + action, CliAction::PrintSystemPrompt { cwd: PathBuf::from("/tmp/project"), date: "2026-04-01".to_string(), + model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, } ); } + #[test] + fn parses_global_model_for_system_prompt() { + // given: a global OpenAI-compatible model before system-prompt + let args = vec![ + "--model".to_string(), + "openai/gpt-4.1-mini".to_string(), + "system-prompt".to_string(), + ]; + + // when: parsing the CLI arguments + let action = parse_args(&args).expect("args should parse"); + + // then: the system-prompt action carries the selected model + match action { + CliAction::PrintSystemPrompt { model, .. } => { + assert_eq!(model, "openai/gpt-4.1-mini"); + } + other => panic!("expected PrintSystemPrompt, got {other:?}"), + } + } + #[test] fn removed_login_and_logout_subcommands_error_helpfully() { let login = parse_args(&["login".to_string()]).expect_err("login should be removed"); diff --git a/rust/crates/rusty-claude-cli/tests/compact_output.rs b/rust/crates/rusty-claude-cli/tests/compact_output.rs index 8e751c0c..4ccca2f4 100644 --- a/rust/crates/rusty-claude-cli/tests/compact_output.rs +++ b/rust/crates/rusty-claude-cli/tests/compact_output.rs @@ -126,6 +126,66 @@ fn compact_flag_streaming_text_only_emits_final_message_text() { fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); } +#[test] +fn text_prompt_mode_prints_final_assistant_text_after_spinner() { + // given a workspace pointed at the mock Anthropic service running the + // streaming_text scenario which only emits a single assistant text block + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + let server = runtime + .block_on(MockAnthropicService::spawn()) + .expect("mock service should start"); + let base_url = server.base_url(); + + let workspace = unique_temp_dir("text-prompt-mode"); + let config_home = workspace.join("config-home"); + let home = workspace.join("home"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + + // when we invoke claw in normal text prompt mode for the streaming text scenario + let prompt = format!("{SCENARIO_PREFIX}streaming_text"); + let output = run_claw( + &workspace, + &config_home, + &home, + &base_url, + &[ + "--model", + "sonnet", + "--permission-mode", + "read-only", + &prompt, + ], + ); + + // then stdout should contain the final assistant text, not just spinner output + assert!( + output.status.success(), + "text prompt run should succeed\nstdout:\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 plain_stdout = strip_ansi_codes(&stdout); + assert!( + plain_stdout.contains("Mock streaming says hello from the parity harness."), + "text prompt stdout should include the assistant text ({stdout:?})" + ); + assert!( + plain_stdout.contains("✔ ✨ Done"), + "text prompt stdout should still include spinner completion ({stdout:?})" + ); + assert!( + plain_stdout + .lines() + .any(|line| line == "Mock streaming says hello from the parity harness."), + "text prompt stdout should print the assistant text as its own line ({stdout:?})" + ); + + fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); +} + #[test] fn compact_flag_with_json_output_emits_structured_json() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); @@ -215,3 +275,21 @@ fn unique_temp_dir(label: &str) -> PathBuf { std::process::id() )) } + +fn strip_ansi_codes(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) { + chars.next(); + while let Some(next) = chars.next() { + if ('@'..='~').contains(&next) { + break; + } + } + continue; + } + output.push(ch); + } + output +} diff --git a/rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs b/rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs new file mode 100644 index 00000000..e930cf43 --- /dev/null +++ b/rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs @@ -0,0 +1,138 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[test] +fn compact_slash_command_in_repl_does_not_start_nested_tokio_runtime() { + // given + let workspace = unique_temp_dir("compact-repl-panic"); + let config_home = workspace.join("config-home"); + let home = workspace.join("home"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + + // when + let output = run_claw_repl(&workspace, &config_home, &home, "/compact\n/exit\n"); + + // then + assert!( + output.status.success(), + "compact repl run should succeed\nstdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8"); + assert!( + !stderr.contains("Cannot start a runtime"), + "stderr must not contain nested runtime panic: {stderr:?}" + ); + assert!( + !stderr.contains("panicked at"), + "stderr must not contain panic output: {stderr:?}" + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let plain_stdout = strip_ansi_codes(&stdout); + assert!( + plain_stdout.contains("Compaction skipped") + || plain_stdout.contains("Result skipped") + || plain_stdout.contains("Result compacted"), + "stdout should contain compact report output ({stdout:?})" + ); + + fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); +} + +fn run_claw_repl( + cwd: &std::path::Path, + config_home: &std::path::Path, + home: &std::path::Path, + stdin: &str, +) -> Output { + let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw")); + let mut child = command + .current_dir(cwd) + .env_clear() + .env("ANTHROPIC_API_KEY", "test-compact-repl-key") + .env("CLAW_CONFIG_HOME", config_home) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("PATH", "/usr/bin:/bin") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("claw should launch"); + + child + .stdin + .as_mut() + .expect("stdin should be piped") + .write_all(stdin.as_bytes()) + .expect("stdin should write"); + + child.wait_with_output().expect("claw should finish") +} + +fn python_pty_command(claw: &str) -> Command { + let mut command = Command::new("python3"); + command.args([ + "-c", + r#" +import os +import pty +import subprocess +import sys + +claw = sys.argv[1] +payload = sys.stdin.buffer.read() +master, slave = pty.openpty() +child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE) +os.close(slave) +os.write(master, payload) +stdout, stderr = child.communicate(timeout=30) +os.close(master) +sys.stdout.buffer.write(stdout) +sys.stderr.buffer.write(stderr) +raise SystemExit(child.returncode) +"#, + claw, + ]); + command +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_millis(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "claw-{label}-{}-{millis}-{counter}", + std::process::id() + )) +} + +fn strip_ansi_codes(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) { + chars.next(); + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + continue; + } + output.push(ch); + } + output +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index f3d1849a..9e669f5e 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -4,9 +4,10 @@ use std::process::Command; use std::time::{Duration, Instant}; use api::{ - max_tokens_for_model, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock, - InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, + max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError, + ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, + OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, + ToolResultContentBlock, }; use plugins::PluginTool; use reqwest::blocking::Client; @@ -3075,27 +3076,33 @@ fn extract_quoted_value(input: &str) -> Option<(String, &str)> { } fn decode_duckduckgo_redirect(url: &str) -> Option { - if url.starts_with("http://") || url.starts_with("https://") { - return Some(html_entity_decode_url(url)); - } - - let joined = if url.starts_with("//") { - format!("https:{url}") - } else if url.starts_with('/') { - format!("https://duckduckgo.com{url}") + let decoded = html_entity_decode_url(url); + let parsed = if decoded.starts_with("http://") || decoded.starts_with("https://") { + reqwest::Url::parse(&decoded).ok() + } else if decoded.starts_with("//") { + reqwest::Url::parse(&format!("https:{decoded}")).ok() + } else if decoded.starts_with('/') { + reqwest::Url::parse(&format!("https://duckduckgo.com{decoded}")).ok() } else { return None; - }; + }?; - let parsed = reqwest::Url::parse(&joined).ok()?; - if parsed.path() == "/l/" || parsed.path() == "/l" { + let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); + if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com")) + && (parsed.path() == "/l/" || parsed.path() == "/l") + { for (key, value) in parsed.query_pairs() { if key == "uddg" { return Some(html_entity_decode_url(value.as_ref())); } } } - Some(joined) + + if decoded.starts_with("http://") || decoded.starts_with("https://") { + Some(decoded) + } else { + Some(parsed.to_string()) + } } fn html_entity_decode_url(url: &str) -> String { @@ -3510,7 +3517,7 @@ where .filter(|name| !name.is_empty()) .unwrap_or_else(|| slugify_agent_name(&input.description)); let created_at = iso8601_now(); - let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?; + let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?; let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type); let output_contents = format!( @@ -3623,13 +3630,14 @@ fn build_agent_runtime( )) } -fn build_agent_system_prompt(subagent_type: &str) -> Result, String> { +fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result, String> { let cwd = std::env::current_dir().map_err(|error| error.to_string())?; let mut prompt = load_system_prompt( cwd, DEFAULT_AGENT_SYSTEM_DATE.to_string(), std::env::consts::OS, "unknown", + model_family_identity_for(model), ) .map_err(|error| error.to_string())?; prompt.push(format!( @@ -4759,6 +4767,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { .iter() .map(|block| match block { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::Thinking { .. } => InputContentBlock::Text { + text: String::new(), + }, ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), @@ -4778,6 +4789,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { is_error: *is_error, }, }) + .filter( + |block| !matches!(block, InputContentBlock::Text { text } if text.is_empty()), + ) .collect::>(); (!content.is_empty()).then(|| InputMessage { role: role.to_string(), @@ -6134,12 +6148,13 @@ mod tests { use std::time::Duration; use super::{ - agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure, - derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome, - final_assistant_text, global_cron_registry, maybe_commit_provenance, mvp_tool_specs, - permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, - run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, - ProviderRuntimeClient, SubagentToolExecutor, + agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt, + classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool, + extract_recovery_outcome, final_assistant_text, global_cron_registry, + maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin, + persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob, + GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient, + SubagentToolExecutor, }; use api::OutputContentBlock; use runtime::ProviderFallbackConfig; @@ -7148,6 +7163,98 @@ mod tests { assert!(error.contains("relative URL without a base") || error.contains("empty host")); } + #[test] + fn web_search_decodes_absolute_duckduckgo_redirect_urls() { + // given + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.contains("GET /search?q=duckduckgo+redirects ")); + HttpResponse::html( + 200, + "OK", + r#" + + Reqwest docs + + "#, + ) + })); + + // when + std::env::set_var( + "CLAWD_WEB_SEARCH_BASE_URL", + format!("http://{}/search", server.addr()), + ); + let result = execute_tool( + "WebSearch", + &json!({ + "query": "duckduckgo redirects" + }), + ) + .expect("WebSearch should succeed"); + std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL"); + + // then + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + let results = output["results"].as_array().expect("results array"); + let search_result = results + .iter() + .find(|item| item.get("content").is_some()) + .expect("search result block present"); + let content = search_result["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["title"], "Reqwest docs"); + assert_eq!(content[0]["url"], "https://docs.rs/reqwest"); + } + + #[test] + fn web_search_decodes_protocol_relative_duckduckgo_redirect_urls() { + // given + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.contains("GET /search?q=duckduckgo+protocol+relative ")); + HttpResponse::html( + 200, + "OK", + r#" + + Tokio Docs + + "#, + ) + })); + + // when + std::env::set_var( + "CLAWD_WEB_SEARCH_BASE_URL", + format!("http://{}/search", server.addr()), + ); + let result = execute_tool( + "WebSearch", + &json!({ + "query": "duckduckgo protocol relative" + }), + ) + .expect("WebSearch should succeed"); + std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL"); + + // then + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + let results = output["results"].as_array().expect("results array"); + let search_result = results + .iter() + .find(|item| item.get("content").is_some()) + .expect("search result block present"); + let content = search_result["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["title"], "Tokio Docs"); + assert_eq!(content[0]["url"], "https://docs.rs/tokio"); + } + #[test] fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() { let mut events = Vec::new(); @@ -8409,6 +8516,28 @@ mod tests { assert!(!verification.contains("write_file")); } + #[test] + fn subagent_system_prompt_uses_resolved_model_identity() { + // given: a temporary workspace and an OpenAI-compatible subagent model + let _guard = env_guard(); + let root = temp_path("subagent-prompt-identity"); + fs::create_dir_all(&root).expect("create temp workspace"); + let previous = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(&root).expect("enter temp workspace"); + + // when: building the subagent system prompt + let prompt = build_agent_system_prompt("Explore", "openai/gpt-4.1-mini") + .expect("subagent system prompt should build") + .join("\n"); + std::env::set_current_dir(previous).expect("restore current dir"); + + // then: the prompt renders a generic model family identity + assert!(prompt.contains("Model family: an AI assistant")); + assert!(!prompt.contains("Model family: Claude Opus 4.6")); + + fs::remove_dir_all(root).expect("cleanup temp workspace"); + } + #[derive(Debug)] struct MockSubagentApiClient { calls: usize, From 357629dbd9b300cbea7f484b6df263a862e20a84 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 6 May 2026 15:41:25 +0900 Subject: [PATCH 18/18] fix(skills): route help flags to local dispatch + fix push_output_block test arity Cherry-pick from Yeachan-Heo's #2945 with manual conflict resolution: - classify_skills_slash_command now catches -h/--help anywhere in args - Restored pending_thinking parameter in push_output_block test calls Co-authored-by: Yeachan-Heo --- rust/crates/commands/src/lib.rs | 7 ++++ rust/crates/tools/src/lib.rs | 62 +++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 454888d7..5e8f5eba 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2490,6 +2490,13 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch { None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => { SkillSlashDispatch::Local } + Some(args) + if args + .split_whitespace() + .any(|part| matches!(part, "-h" | "--help")) => + { + SkillSlashDispatch::Local + } Some(args) if args == "install" || args.starts_with("install ") => { SkillSlashDispatch::Local } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 9e669f5e..eb4a3905 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -4638,13 +4638,21 @@ async fn stream_with_provider( let mut stream = client.stream_message(message_request).await?; let mut events = Vec::new(); let mut pending_tools: BTreeMap = BTreeMap::new(); + let mut pending_thinking: BTreeMap)> = BTreeMap::new(); let mut saw_stop = false; while let Some(event) = stream.next_event().await? { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { - push_output_block(block, 0, &mut events, &mut pending_tools, true); + push_output_block( + block, + 0, + &mut events, + &mut pending_tools, + &mut pending_thinking, + true, + ); } } ApiStreamEvent::ContentBlockStart(start) => { @@ -4653,6 +4661,7 @@ async fn stream_with_provider( start.index, &mut events, &mut pending_tools, + &mut pending_thinking, true, ); } @@ -4667,10 +4676,23 @@ async fn stream_with_provider( input.push_str(&partial_json); } } - ContentBlockDelta::ThinkingDelta { .. } - | ContentBlockDelta::SignatureDelta { .. } => {} + ContentBlockDelta::ThinkingDelta { thinking } => { + if let Some((pending, _)) = pending_thinking.get_mut(&delta.index) { + pending.push_str(&thinking); + } + } + ContentBlockDelta::SignatureDelta { signature } => { + if let Some((_, pending_signature)) = pending_thinking.get_mut(&delta.index) { + pending_signature + .get_or_insert_with(String::new) + .push_str(&signature); + } + } }, ApiStreamEvent::ContentBlockStop(stop) => { + if let Some((thinking, signature)) = pending_thinking.remove(&stop.index) { + events.push(AssistantEvent::Thinking { thinking, signature }); + } if let Some((id, name, input)) = pending_tools.remove(&stop.index) { events.push(AssistantEvent::ToolUse { id, name, input }); } @@ -4767,8 +4789,12 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { .iter() .map(|block| match block { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::Thinking { .. } => InputContentBlock::Text { - text: String::new(), + ContentBlock::Thinking { + thinking, + signature, + } => InputContentBlock::Thinking { + thinking: thinking.clone(), + signature: signature.clone(), }, ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id: id.clone(), @@ -4806,6 +4832,7 @@ fn push_output_block( block_index: u32, events: &mut Vec, pending_tools: &mut BTreeMap, + pending_thinking: &mut BTreeMap)>, streaming_tool_input: bool, ) { match block { @@ -4825,17 +4852,35 @@ fn push_output_block( }; pending_tools.insert(block_index, (id, name, initial_input)); } - OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} + OutputContentBlock::Thinking { + thinking, + signature, + } => { + if streaming_tool_input { + pending_thinking.insert(block_index, (thinking, signature)); + } else { + events.push(AssistantEvent::Thinking { thinking, signature }); + } + } + OutputContentBlock::RedactedThinking { .. } => {} } } fn response_to_events(response: MessageResponse) -> Vec { let mut events = Vec::new(); let mut pending_tools = BTreeMap::new(); + let mut pending_thinking = BTreeMap::new(); for (index, block) in response.content.into_iter().enumerate() { let index = u32::try_from(index).expect("response block index overflow"); - push_output_block(block, index, &mut events, &mut pending_tools, false); + push_output_block( + block, + index, + &mut events, + &mut pending_tools, + &mut pending_thinking, + false, + ); if let Some((id, name, input)) = pending_tools.remove(&index) { events.push(AssistantEvent::ToolUse { id, name, input }); } @@ -7259,6 +7304,7 @@ mod tests { fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() { let mut events = Vec::new(); let mut pending_tools = BTreeMap::new(); + let mut pending_thinking = BTreeMap::new(); push_output_block( OutputContentBlock::ToolUse { @@ -7269,6 +7315,7 @@ mod tests { 1, &mut events, &mut pending_tools, + &mut pending_thinking, true, ); push_output_block( @@ -7280,6 +7327,7 @@ mod tests { 2, &mut events, &mut pending_tools, + &mut pending_thinking, true, );