Преглед изворни кода

fix: critical parity bugs - enable tools, default permissions, tool input

Tighten prompt-mode parity for the Rust CLI by enabling native tools in one-shot runs, defaulting fresh sessions to danger-full-access, and documenting the remaining TS-vs-Rust gaps.

The JSON prompt path now runs through the full conversation loop so tool use and tool results are preserved without streaming terminal noise, while the tool-input accumulator keeps the streaming {} placeholder fix without corrupting legitimate non-stream empty objects.

Constraint: Original TypeScript source was treated as read-only for parity analysis
Constraint: No new dependencies; keep the fix localized to the Rust port
Rejected: Leave JSON prompt mode on a direct non-tool API path | preserved the one-shot parity bug
Rejected: Keep workspace-write as the default permission mode | contradicted requested parity target
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep prompt text and prompt JSON paths on the same tool-capable runtime semantics unless upstream behavior proves they must diverge
Tested: cargo build --release; cargo test
Not-tested: live remote prompt run against LayoffLabs endpoint in this session
Yeachan-Heo пре 2 месеци
родитељ
комит
b50ee29c08

+ 214 - 0
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.

+ 2 - 2
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")));
     }
 }

+ 7 - 1
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);
+    }
 }

+ 2 - 2
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",
             )

+ 248 - 85
rust/crates/rusty-claude-cli/src/main.rs

@@ -78,7 +78,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             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<tools::ToolSpec> {
@@ -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<dyn std::error::Error>> {
-        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::<Vec<_>>()
-            .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<String>,
     enable_tools: bool,
+    emit_output: bool,
     allowed_tools: Option<AllowedToolSet>,
     permission_mode: PermissionMode,
 ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
 {
     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<AllowedToolSet>,
 }
 
@@ -1952,6 +1951,7 @@ impl AnthropicRuntimeClient {
     fn new(
         model: String,
         enable_tools: bool,
+        emit_output: bool,
         allowed_tools: Option<AllowedToolSet>,
     ) -> Result<Self, Box<dyn std::error::Error>> {
         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::<Vec<_>>()
+                .join("")
+        })
+        .unwrap_or_default()
+}
+
+fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
+    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<serde_json::Value> {
+    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<String> {
     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::<serde_json::Value>(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::<String>();
@@ -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<AssistantEvent>,
     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<Vec<AssistantEvent>, 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<AllowedToolSet>,
 }
 
 impl CliToolExecutor {
-    fn new(allowed_tools: Option<AllowedToolSet>) -> Self {
+    fn new(allowed_tools: Option<AllowedToolSet>, 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\"}"
+        ));
+    }
 }