diff --git a/PARITY.md b/PARITY.md new file mode 100644 index 0000000..dd0deb7 --- /dev/null +++ b/PARITY.md @@ -0,0 +1,214 @@ +# PARITY GAP ANALYSIS + +Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`. + +Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied. + +## Executive summary + +The Rust port has a good foundation for: +- Anthropic API/OAuth basics +- local conversation/session state +- a core tool loop +- MCP stdio/bootstrap support +- CLAUDE.md discovery +- a small but usable built-in tool set + +It is **not feature-parity** with the TypeScript CLI. + +Largest gaps: +- **plugins** are effectively absent in Rust +- **hooks** are parsed but not executed in Rust +- **CLI breadth** is much narrower in Rust +- **skills** are local-file only in Rust, without the TS registry/bundled pipeline +- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports +- **services** beyond core API/OAuth/MCP are mostly missing in Rust + +--- + +## tools/ + +### TS exists +Evidence: +- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`. +- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`. + +### Rust exists +Evidence: +- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`. +- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives. +- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`. + +### Missing or broken in Rust +- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools. +- Rust tool surface is still explicitly an MVP registry, not a parity registry. +- Rust lacks TS’s layered tool orchestration split. + +**Status:** partial core only. + +--- + +## hooks/ + +### TS exists +Evidence: +- Hook command surface under `src/commands/hooks/`. +- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`. +- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`. + +### Rust exists +Evidence: +- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`. +- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`. +- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`. + +### Missing or broken in Rust +- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`. +- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior. +- No Rust `/hooks` parity command. + +**Status:** config-only; runtime behavior missing. + +--- + +## plugins/ + +### TS exists +Evidence: +- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`. +- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`. +- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`. + +### Rust exists +Evidence: +- No dedicated plugin subsystem appears under `rust/crates/`. +- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions. + +### Missing or broken in Rust +- No plugin loader. +- No marketplace install/update/enable/disable flow. +- No `/plugin` or `/reload-plugins` parity. +- No plugin-provided hook/tool/command/MCP extension path. + +**Status:** missing. + +--- + +## skills/ and CLAUDE.md discovery + +### TS exists +Evidence: +- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`. +- Bundled skills under `src/skills/bundled/`. +- Skills command surface under `src/commands/skills/`. + +### Rust exists +Evidence: +- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files. +- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`. +- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`. + +### Missing or broken in Rust +- No bundled skill registry equivalent. +- No `/skills` command. +- No MCP skill-builder pipeline. +- No TS-style live skill discovery/reload/change handling. +- No comparable session-memory / team-memory integration around skills. + +**Status:** basic local skill loading only. + +--- + +## cli/ + +### TS exists +Evidence: +- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more. +- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`. +- CLI handler split in `src/cli/handlers/*`. + +### Rust exists +Evidence: +- Shared slash command registry in `rust/crates/commands/src/lib.rs`. +- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`. +- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`. + +### Missing or broken in Rust +- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others. +- No Rust equivalent to TS structured IO / remote transport layers. +- No TS-style handler decomposition for auth/plugins/MCP/agents. +- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object. + +**Status:** functional local CLI core, much narrower than TS. + +--- + +## assistant/ (agentic loop, streaming, tool calling) + +### TS exists +Evidence: +- Assistant/session surface at `src/assistant/sessionHistory.ts`. +- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`. +- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`. + +### Rust exists +Evidence: +- Core loop in `rust/crates/runtime/src/conversation.rs`. +- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`. +- Session persistence in `rust/crates/runtime/src/session.rs`. + +### Missing or broken in Rust +- No TS-style hook-aware orchestration layer. +- No TS structured/remote assistant transport stack. +- No richer TS assistant/session-history/background-task integration. +- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations. + +**Status:** strong core loop, missing orchestration layers. + +--- + +## services/ (API client, auth, models, MCP) + +### TS exists +Evidence: +- API services under `src/services/api/*`. +- OAuth services under `src/services/oauth/*`. +- MCP services under `src/services/mcp/*`. +- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`. + +### Rust exists +Evidence: +- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`. +- OAuth support in `rust/crates/runtime/src/oauth.rs`. +- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`. +- Usage accounting in `rust/crates/runtime/src/usage.rs`. +- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`. + +### Missing or broken in Rust +- Most TS service ecosystem beyond core messaging/auth/MCP is absent. +- No TS-equivalent plugin service layer. +- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems. +- No TS-style MCP connection-manager/UI layer. +- Model/provider ergonomics remain thinner than TS. + +**Status:** core foundation exists; broader service ecosystem missing. + +--- + +## Critical bug status in this worktree + +### Fixed +- **Prompt mode tools enabled** + - `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`. +- **Default permission mode = DangerFullAccess** + - Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`. + - Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`. + - Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`. +- **Streaming `{}` tool-input prefix bug** + - `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses. +- **Unlimited max_iterations** + - Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`. + +### Remaining notable parity issue +- **JSON prompt output cleanliness** + - Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire. diff --git a/rust/crates/rusty-claude-cli/src/app.rs b/rust/crates/rusty-claude-cli/src/app.rs index b2864a3..0454427 100644 --- a/rust/crates/rusty-claude-cli/src/app.rs +++ b/rust/crates/rusty-claude-cli/src/app.rs @@ -386,13 +386,13 @@ mod tests { fn session_state_tracks_config_values() { let config = SessionConfig { model: "claude".into(), - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, config: Some(PathBuf::from("settings.toml")), output_format: OutputFormat::Text, }; assert_eq!(config.model, "claude"); - assert_eq!(config.permission_mode, PermissionMode::WorkspaceWrite); + assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess); assert_eq!(config.config, Some(PathBuf::from("settings.toml"))); } } diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs index 990beb4..e36934a 100644 --- a/rust/crates/rusty-claude-cli/src/args.rs +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -12,7 +12,7 @@ pub struct Cli { #[arg(long, default_value = "claude-opus-4-6")] pub model: String, - #[arg(long, value_enum, default_value_t = PermissionMode::WorkspaceWrite)] + #[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)] pub permission_mode: PermissionMode, #[arg(long)] @@ -99,4 +99,10 @@ mod tests { let logout = Cli::parse_from(["rusty-claude-cli", "logout"]); assert_eq!(logout.command, Some(Command::Logout)); } + + #[test] + fn defaults_to_danger_full_access_permission_mode() { + let cli = Cli::parse_from(["rusty-claude-cli"]); + assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess); + } } diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs index 4847c0a..8b30d29 100644 --- a/rust/crates/rusty-claude-cli/src/init.rs +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; const STARTER_CLAUDE_JSON: &str = concat!( "{\n", " \"permissions\": {\n", - " \"defaultMode\": \"acceptEdits\"\n", + " \"defaultMode\": \"dontAsk\"\n", " }\n", "}\n", ); @@ -366,7 +366,7 @@ mod tests { concat!( "{\n", " \"permissions\": {\n", - " \"defaultMode\": \"acceptEdits\"\n", + " \"defaultMode\": \"dontAsk\"\n", " }\n", "}\n", ) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f733591..45f6922 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -78,7 +78,7 @@ fn run() -> Result<(), Box> { output_format, allowed_tools, permission_mode, - } => LiveCli::new(model, false, allowed_tools, permission_mode)? + } => LiveCli::new(model, true, allowed_tools, permission_mode)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, @@ -350,7 +350,7 @@ fn default_permission_mode() -> PermissionMode { .ok() .as_deref() .and_then(normalize_permission_mode) - .map_or(PermissionMode::WorkspaceWrite, permission_mode_from_label) + .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) } fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec { @@ -968,6 +968,7 @@ impl LiveCli { model.clone(), system_prompt.clone(), enable_tools, + true, allowed_tools.clone(), permission_mode, )?; @@ -1052,43 +1053,33 @@ impl LiveCli { } fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { - let client = AnthropicClient::from_auth(resolve_cli_auth_source()?) - .with_base_url(api::read_base_url()); - let request = MessageRequest { - model: self.model.clone(), - max_tokens: max_tokens_for_model(&self.model), - messages: vec![InputMessage { - role: "user".to_string(), - content: vec![InputContentBlock::Text { - text: input.to_string(), - }], - }], - system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), - tools: None, - tool_choice: None, - stream: false, - }; - let runtime = tokio::runtime::Runtime::new()?; - let response = runtime.block_on(client.send_message(&request))?; - let text = response - .content - .iter() - .filter_map(|block| match block { - OutputContentBlock::Text { text } => Some(text.as_str()), - OutputContentBlock::ToolUse { .. } => None, - }) - .collect::>() - .join(""); + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + false, + self.allowed_tools.clone(), + self.permission_mode, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; + self.runtime = runtime; + self.persist_session()?; println!( "{}", json!({ - "message": text, + "message": final_assistant_text(&summary), "model": self.model, + "iterations": summary.iterations, + "tool_uses": collect_tool_uses(&summary), + "tool_results": collect_tool_results(&summary), "usage": { - "input_tokens": response.usage.input_tokens, - "output_tokens": response.usage.output_tokens, - "cache_creation_input_tokens": response.usage.cache_creation_input_tokens, - "cache_read_input_tokens": response.usage.cache_read_input_tokens, + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, } }) ); @@ -1214,6 +1205,7 @@ impl LiveCli { model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1256,6 +1248,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1280,6 +1273,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1314,6 +1308,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1385,6 +1380,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1414,6 +1410,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + true, self.allowed_tools.clone(), self.permission_mode, )?; @@ -1881,14 +1878,15 @@ fn build_runtime( model: String, system_prompt: Vec, enable_tools: bool, + emit_output: bool, allowed_tools: Option, permission_mode: PermissionMode, ) -> Result, Box> { Ok(ConversationRuntime::new( session, - AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, - CliToolExecutor::new(allowed_tools), + AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, + CliToolExecutor::new(allowed_tools, emit_output), permission_policy(permission_mode), system_prompt, )) @@ -1945,6 +1943,7 @@ struct AnthropicRuntimeClient { client: AnthropicClient, model: String, enable_tools: bool, + emit_output: bool, allowed_tools: Option, } @@ -1952,6 +1951,7 @@ impl AnthropicRuntimeClient { fn new( model: String, enable_tools: bool, + emit_output: bool, allowed_tools: Option, ) -> Result> { Ok(Self { @@ -1960,6 +1960,7 @@ impl AnthropicRuntimeClient { .with_base_url(api::read_base_url()), model, enable_tools, + emit_output, allowed_tools, }) } @@ -2004,6 +2005,12 @@ impl ApiClient for AnthropicRuntimeClient { .await .map_err(|error| RuntimeError::new(error.to_string()))?; let mut stdout = io::stdout(); + let mut sink = io::sink(); + let out: &mut dyn Write = if self.emit_output { + &mut stdout + } else { + &mut sink + }; let mut events = Vec::new(); let mut pending_tool: Option<(String, String, String)> = None; let mut saw_stop = false; @@ -2016,22 +2023,23 @@ impl ApiClient for AnthropicRuntimeClient { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { - push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?; + push_output_block(block, out, &mut events, &mut pending_tool, true)?; } } ApiStreamEvent::ContentBlockStart(start) => { push_output_block( start.content_block, - &mut stdout, + out, &mut events, &mut pending_tool, + true, )?; } ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { - write!(stdout, "{text}") - .and_then(|()| stdout.flush()) + write!(out, "{text}") + .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::TextDelta(text)); } @@ -2045,13 +2053,9 @@ impl ApiClient for AnthropicRuntimeClient { ApiStreamEvent::ContentBlockStop(_) => { if let Some((id, name, input)) = pending_tool.take() { // Display tool call now that input is fully accumulated - writeln!( - stdout, - "\n{}", - format_tool_call_start(&name, &input) - ) - .and_then(|()| stdout.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; + writeln!(out, "\n{}", format_tool_call_start(&name, &input)) + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::ToolUse { id, name, input }); } } @@ -2094,11 +2098,67 @@ impl ApiClient for AnthropicRuntimeClient { }) .await .map_err(|error| RuntimeError::new(error.to_string()))?; - response_to_events(response, &mut stdout) + response_to_events(response, out) }) } } +fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some(json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + fn slash_command_completion_candidates() -> Vec { slash_command_specs() .iter() @@ -2131,8 +2191,7 @@ fn format_tool_call_start(name: &str, input: &str) -> String { let lines = parsed .get("content") .and_then(|v| v.as_str()) - .map(|c| c.lines().count()) - .unwrap_or(0); + .map_or(0, |c| c.lines().count()); format!("{path} ({lines} lines)") } "edit_file" | "Edit" => { @@ -2185,13 +2244,6 @@ fn summarize_tool_payload(payload: &str) -> String { truncate_for_summary(&compact, 96) } -fn prettify_tool_payload(payload: &str) -> String { - match serde_json::from_str::(payload) { - Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()), - Err(_) => payload.to_string(), - } -} - fn truncate_for_summary(value: &str, limit: usize) -> String { let mut chars = value.chars(); let truncated = chars.by_ref().take(limit).collect::(); @@ -2204,9 +2256,10 @@ fn truncate_for_summary(value: &str, limit: usize) -> String { fn push_output_block( block: OutputContentBlock, - out: &mut impl Write, + out: &mut (impl Write + ?Sized), events: &mut Vec, pending_tool: &mut Option<(String, String, String)>, + streaming_tool_input: bool, ) -> Result<(), RuntimeError> { match block { OutputContentBlock::Text { text } => { @@ -2219,9 +2272,12 @@ fn push_output_block( } OutputContentBlock::ToolUse { id, name, input } => { // During streaming, the initial content_block_start has an empty input ({}). - // The real input arrives via input_json_delta events. - // Start with empty string so deltas build the correct JSON. - let initial_input = if input.is_object() && input.as_object().map_or(false, |o| o.is_empty()) { + // The real input arrives via input_json_delta events. In + // non-streaming responses, preserve a legitimate empty object. + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { String::new() } else { input.to_string() @@ -2234,13 +2290,13 @@ fn push_output_block( fn response_to_events( response: MessageResponse, - out: &mut impl Write, + out: &mut (impl Write + ?Sized), ) -> Result, RuntimeError> { let mut events = Vec::new(); let mut pending_tool = None; for block in response.content { - push_output_block(block, out, &mut events, &mut pending_tool)?; + push_output_block(block, out, &mut events, &mut pending_tool, false)?; if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } @@ -2258,13 +2314,15 @@ fn response_to_events( struct CliToolExecutor { renderer: TerminalRenderer, + emit_output: bool, allowed_tools: Option, } impl CliToolExecutor { - fn new(allowed_tools: Option) -> Self { + fn new(allowed_tools: Option, emit_output: bool) -> Self { Self { renderer: TerminalRenderer::new(), + emit_output, allowed_tools, } } @@ -2285,17 +2343,21 @@ impl ToolExecutor for CliToolExecutor { .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; match execute_tool(tool_name, &value) { Ok(output) => { - let markdown = format_tool_result(tool_name, &output, false); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|error| ToolError::new(error.to_string()))?; + if self.emit_output { + let markdown = format_tool_result(tool_name, &output, false); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string()))?; + } Ok(output) } Err(error) => { - let markdown = format_tool_result(tool_name, &error, true); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + if self.emit_output { + let markdown = format_tool_result(tool_name, &error, true); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + } Err(ToolError::new(error)) } } @@ -2402,7 +2464,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" )?; - writeln!(out, " --dangerously-skip-permissions Skip all permission checks")?; + writeln!( + out, + " --dangerously-skip-permissions Skip all permission checks" + )?; writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; writeln!( out, @@ -2451,11 +2516,13 @@ mod tests { format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, - render_config_report, render_memory_report, render_repl_help, resolve_model_alias, - resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, - StatusUsage, DEFAULT_MODEL, + push_output_block, render_config_report, render_memory_report, render_repl_help, + resolve_model_alias, response_to_events, resume_supported_slash_commands, status_context, + CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use api::{MessageResponse, OutputContentBlock, Usage}; + use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use serde_json::json; use std::path::PathBuf; #[test] @@ -2465,7 +2532,7 @@ mod tests { CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: None, - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, } ); } @@ -2484,7 +2551,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, } ); } @@ -2505,7 +2572,7 @@ mod tests { model: "claude-opus".to_string(), output_format: CliOutputFormat::Json, allowed_tools: None, - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, } ); } @@ -2525,7 +2592,7 @@ mod tests { model: "claude-opus-4-6".to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, } ); } @@ -2534,7 +2601,7 @@ mod tests { fn resolves_known_model_aliases() { assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); - assert_eq!(resolve_model_alias("haiku"), "claude-haiku-3-5-20241022"); + assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213"); assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); } @@ -2580,7 +2647,7 @@ mod tests { .map(str::to_string) .collect() ), - permission_mode: PermissionMode::WorkspaceWrite, + permission_mode: PermissionMode::DangerFullAccess, } ); } @@ -2986,11 +3053,107 @@ mod tests { #[test] fn tool_rendering_helpers_compact_output() { let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); - assert!(start.contains("Tool call")); + assert!(start.contains("read_file")); assert!(start.contains("src/main.rs")); let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false); - assert!(done.contains("Tool `read_file`")); + assert!(done.contains("read_file:")); assert!(done.contains("contents")); } + + #[test] + fn push_output_block_skips_empty_object_prefix_for_tool_streams() { + let mut out = Vec::new(); + let mut events = Vec::new(); + let mut pending_tool = None; + + push_output_block( + OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({}), + }, + &mut out, + &mut events, + &mut pending_tool, + true, + ) + .expect("tool block should accumulate"); + + assert!(events.is_empty()); + assert_eq!( + pending_tool, + Some(("tool-1".to_string(), "read_file".to_string(), String::new(),)) + ); + } + + #[test] + fn response_to_events_preserves_empty_object_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-1".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({}), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{}" + )); + } + + #[test] + fn response_to_events_preserves_non_empty_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-2".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-2".to_string(), + name: "read_file".to_string(), + input: json!({ "path": "rust/Cargo.toml" }), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" + )); + } }