Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions crates/zeph-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading