diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index 93905c17..dff9ee47 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -1159,6 +1159,7 @@ pub(super) mod agent_tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, }))]); let agent_channel = MockChannel::new(vec!["execute tool".to_string()]); @@ -1358,6 +1359,7 @@ pub(super) mod agent_tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })), Ok(None), ]); @@ -1379,6 +1381,7 @@ pub(super) mod agent_tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, }))]); let mut agent = Agent::new(provider, channel, registry, None, 5, executor); @@ -1472,6 +1475,7 @@ pub(super) mod agent_tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })), Ok(None), ]); @@ -1501,6 +1505,7 @@ pub(super) mod agent_tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, }))); } let executor = MockToolExecutor::new(outputs); diff --git a/crates/zeph-core/src/agent/streaming.rs b/crates/zeph-core/src/agent/streaming.rs index eecf2855..87497312 100644 --- a/crates/zeph-core/src/agent/streaming.rs +++ b/crates/zeph-core/src/agent/streaming.rs @@ -661,7 +661,7 @@ impl Agent { // Process results sequentially (metrics, channel sends, message parts) let mut result_parts: Vec = Vec::new(); for (tc, tool_result) in tool_calls.iter().zip(tool_results) { - let (output, is_error, diff, inline_stats) = match tool_result { + let (output, is_error, diff, inline_stats, already_streamed) = match tool_result { Ok(Some(out)) => { if let Some(ref fs) = out.filter_stats { let saved = fs.estimated_tokens_saved() as u64; @@ -694,10 +694,11 @@ impl Agent { let inline_stats = out.filter_stats.as_ref().and_then(|fs| { (fs.filtered_chars < fs.raw_chars).then(|| fs.format_inline(&tc.name)) }); - (out.summary, false, out.diff, inline_stats) + let streamed = out.streamed; + (out.summary, false, out.diff, inline_stats, streamed) } - Ok(None) => ("(no output)".to_owned(), false, None, None), - Err(e) => (format!("[error] {e}"), true, None, None), + Ok(None) => ("(no output)".to_owned(), false, None, None, false), + Err(e) => (format!("[error] {e}"), true, None, None, false), }; let processed = self.maybe_summarize_tool_output(&output).await; @@ -708,18 +709,13 @@ impl Agent { }; let formatted = format_tool_output(&tc.name, &body); let display = self.maybe_redact(&formatted); - // Bundle diff and filter stats into a single atomic send so TUI can attach - // them to the tool message without a race between DiffReady and FullMessage. - let disp_len = display.len(); - tracing::debug!( - tool_name = %tc.name, - has_diff = diff.is_some(), - disp_len, - "about to call send_tool_output" - ); - self.channel - .send_tool_output(&tc.name, &display, diff, inline_stats) - .await?; + // Tools that already streamed via ToolEvent channel (e.g. bash) have their + // output displayed by the TUI event forwarder; skip duplicate send. + if !already_streamed { + self.channel + .send_tool_output(&tc.name, &display, diff, inline_stats) + .await?; + } result_parts.push(MessagePart::ToolResult { tool_use_id: tc.id.clone(), @@ -808,6 +804,7 @@ mod tests { blocks_executed: 1, diff: None, filter_stats: None, + streamed: false, })) } } @@ -846,6 +843,7 @@ mod tests { blocks_executed: 1, diff: None, filter_stats: None, + streamed: false, })) } } diff --git a/crates/zeph-mcp/src/executor.rs b/crates/zeph-mcp/src/executor.rs index 9caab457..e6e331b8 100644 --- a/crates/zeph-mcp/src/executor.rs +++ b/crates/zeph-mcp/src/executor.rs @@ -69,6 +69,7 @@ impl ToolExecutor for McpToolExecutor { blocks_executed, filter_stats: None, diff: None, + streamed: false, })) } } diff --git a/crates/zeph-tools/src/composite.rs b/crates/zeph-tools/src/composite.rs index 0621bf25..217e8621 100644 --- a/crates/zeph-tools/src/composite.rs +++ b/crates/zeph-tools/src/composite.rs @@ -61,6 +61,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -93,6 +94,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -170,6 +172,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } else { Ok(None) @@ -194,6 +197,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } else { Ok(None) diff --git a/crates/zeph-tools/src/executor.rs b/crates/zeph-tools/src/executor.rs index fd52f819..ba7f1d69 100644 --- a/crates/zeph-tools/src/executor.rs +++ b/crates/zeph-tools/src/executor.rs @@ -73,6 +73,8 @@ pub struct ToolOutput { pub blocks_executed: u32, pub filter_stats: Option, pub diff: Option, + /// Whether this tool already streamed its output via `ToolEvent` channel. + pub streamed: bool, } impl fmt::Display for ToolOutput { @@ -213,6 +215,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, }; assert_eq!(output.to_string(), "$ echo hello\nhello"); } diff --git a/crates/zeph-tools/src/file.rs b/crates/zeph-tools/src/file.rs index fb99ec01..0e492245 100644 --- a/crates/zeph-tools/src/file.rs +++ b/crates/zeph-tools/src/file.rs @@ -145,6 +145,7 @@ impl FileExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } @@ -173,6 +174,7 @@ impl FileExecutor { old_content, new_content: content, }), + streamed: false, })) } @@ -206,6 +208,7 @@ impl FileExecutor { old_content: content, new_content, }), + streamed: false, })) } @@ -239,6 +242,7 @@ impl FileExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } @@ -282,6 +286,7 @@ impl FileExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } diff --git a/crates/zeph-tools/src/scrape.rs b/crates/zeph-tools/src/scrape.rs index 7354e3be..ab852389 100644 --- a/crates/zeph-tools/src/scrape.rs +++ b/crates/zeph-tools/src/scrape.rs @@ -107,6 +107,7 @@ impl ToolExecutor for WebScrapeExecutor { blocks_executed, filter_stats: None, diff: None, + streamed: false, })) } @@ -136,6 +137,7 @@ impl ToolExecutor for WebScrapeExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } diff --git a/crates/zeph-tools/src/shell.rs b/crates/zeph-tools/src/shell.rs index 3d4f807e..89c09fed 100644 --- a/crates/zeph-tools/src/shell.rs +++ b/crates/zeph-tools/src/shell.rs @@ -268,6 +268,7 @@ impl ShellExecutor { blocks_executed, filter_stats: cumulative_filter_stats, diff: None, + streamed: self.tool_event_tx.is_some(), })) } diff --git a/crates/zeph-tools/src/trust_gate.rs b/crates/zeph-tools/src/trust_gate.rs index 6674cb7e..e9d1f7bc 100644 --- a/crates/zeph-tools/src/trust_gate.rs +++ b/crates/zeph-tools/src/trust_gate.rs @@ -120,6 +120,7 @@ mod tests { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } diff --git a/tests/integration.rs b/tests/integration.rs index 9fec248b..fb30d090 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -167,6 +167,7 @@ impl ToolExecutor for OutputToolExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -181,6 +182,7 @@ impl ToolExecutor for EmptyOutputToolExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -195,6 +197,7 @@ impl ToolExecutor for ErrorOutputToolExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -225,6 +228,7 @@ impl ToolExecutor for ConfirmToolExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -260,6 +264,7 @@ impl ToolExecutor for ExitCodeToolExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } @@ -2167,6 +2172,7 @@ mod self_learning { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } } diff --git a/tests/performance_agent_integration.rs b/tests/performance_agent_integration.rs index 6417c930..0823def8 100644 --- a/tests/performance_agent_integration.rs +++ b/tests/performance_agent_integration.rs @@ -102,6 +102,7 @@ impl ToolExecutor for InstrumentedMockExecutor { blocks_executed: 1, filter_stats: None, diff: None, + streamed: false, })) } else { Ok(None)