From 4ef7e2eee9b059cbb1b57c2e8198edf07de64917 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Sun, 22 Mar 2026 15:04:45 +0100 Subject: [PATCH] fix(tui): replace streaming chunk append with canonical body_display on ToolOutput (#2126) After #2116 fixed duplicate tool entries, the content within each entry was still duplicated: ToolStart wrote "$ cmd\n", OutputChunks appended raw output, then ToolOutput appended the full body_display again. handle_tool_output_event now truncates the message to the header line ("$ cmd\n") before writing body_display, discarding accumulated chunks. Streaming chunks remain a live preview; ToolOutput is the ground truth. --- CHANGELOG.md | 1 + crates/zeph-tui/src/app.rs | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf80c35..8b5bfe6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- fix(tui): tool output content no longer duplicated within a single entry (#2126) — `handle_tool_output_event` now truncates streaming chunks accumulated during ToolStart/ToolOutputChunk and replaces them with the canonical `body_display` from ToolOutput; streaming chunks served as a live preview and are discarded when the final output arrives, eliminating the double `$ cmd\noutput` appearance - fix(policy): `PolicyGateExecutor::set_effective_trust` now updates `PolicyContext.trust_level` so trust_level-based policy rules are evaluated against the actual invoking skill trust tier instead of the hardcoded `Trusted` default (#2112) ### Testing diff --git a/crates/zeph-tui/src/app.rs b/crates/zeph-tui/src/app.rs index d0396162..3f439d24 100644 --- a/crates/zeph-tui/src/app.rs +++ b/crates/zeph-tui/src/app.rs @@ -588,7 +588,13 @@ impl App { .rposition(|m| m.role == MessageRole::Tool && m.streaming) { // Finalize existing streaming tool message (shell or native path with ToolStart). + // Replace content after the header line ("$ cmd\n") with the canonical body_display + // from ToolOutputEvent. Streaming chunks (Path B) may already occupy that space; + // appending would duplicate the output. Truncating to the header and re-writing + // body_display produces exactly one copy regardless of whether chunks arrived. debug!("finalizing existing streaming Tool message"); + let header_end = self.messages[pos].content.find('\n').map_or(0, |i| i + 1); + self.messages[pos].content.truncate(header_end); self.messages[pos].content.push_str(&output); self.messages[pos].streaming = false; self.messages[pos].diff_data = diff; @@ -3355,4 +3361,64 @@ mod tests { drop(tx); } + + // Regression tests for #2126: tool output must not be duplicated when streaming chunks + // arrive before the final ToolOutput event. + + #[test] + fn tool_output_with_prior_tool_start_no_chunks_appends_output() { + let (mut app, _rx, _tx) = make_app(); + // Path A: ToolStart creates message with header only. + app.handle_agent_event(AgentEvent::ToolStart { + tool_name: "bash".into(), + command: "ls -la".into(), + }); + // Path C: ToolOutput arrives with no prior chunks. + app.handle_agent_event(AgentEvent::ToolOutput { + tool_name: "bash".into(), + command: "ls -la".into(), + output: "file1\nfile2\n".into(), + success: true, + diff: None, + filter_stats: None, + kept_lines: None, + }); + + assert_eq!(app.messages().len(), 1); + let msg = &app.messages()[0]; + assert_eq!(msg.content, "$ ls -la\nfile1\nfile2\n"); + assert!(!msg.streaming); + } + + #[test] + fn tool_output_with_prior_tool_start_and_chunks_does_not_duplicate() { + let (mut app, _rx, _tx) = make_app(); + // Path A: ToolStart. + app.handle_agent_event(AgentEvent::ToolStart { + tool_name: "bash".into(), + command: "echo hello".into(), + }); + // Path B: streaming chunks arrive. + app.handle_agent_event(AgentEvent::ToolOutputChunk { + tool_name: "bash".into(), + command: "echo hello".into(), + chunk: "hello\n".into(), + }); + // Path C: ToolOutput with canonical body_display (same content as chunks). + app.handle_agent_event(AgentEvent::ToolOutput { + tool_name: "bash".into(), + command: "echo hello".into(), + output: "hello\n".into(), + success: true, + diff: None, + filter_stats: None, + kept_lines: None, + }); + + assert_eq!(app.messages().len(), 1); + let msg = &app.messages()[0]; + // Must contain exactly one copy of "hello\n", not two. + assert_eq!(msg.content, "$ echo hello\nhello\n"); + assert!(!msg.streaming); + } }