From 74fa8f447ec1158465c670059ad387c5d2c42b08 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 14 May 2026 13:42:35 +0800 Subject: [PATCH 1/4] Fix transcript overlay scroll stutter --- crates/tui/src/chatwidget.rs | 43 ++++ crates/tui/src/host_overlay.rs | 38 ++- crates/tui/src/pager_overlay.rs | 400 ++++++++++++++++++++++++++++---- 3 files changed, 430 insertions(+), 51 deletions(-) diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 0409d3f..04d0091 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -137,6 +137,13 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } +/// Snapshot of one committed transcript cell for the Ctrl+T overlay. +#[derive(Clone, Debug)] +pub(crate) struct TranscriptOverlayCell { + pub(crate) lines: Vec>, + pub(crate) is_stream_continuation: bool, +} + #[derive(Debug, Clone, PartialEq, Default)] pub(crate) struct UserMessage { pub(crate) text: String, @@ -2739,6 +2746,42 @@ impl ChatWidget { .unwrap_or_default() } + pub(crate) fn transcript_overlay_cell_count(&self) -> usize { + self.history.len() + } + + pub(crate) fn transcript_overlay_cells(&self, width: u16) -> Vec { + let width = width.max(1); + self.history + .iter() + .map(|cell| TranscriptOverlayCell { + lines: cell.transcript_lines(width), + is_stream_continuation: cell.is_stream_continuation(), + }) + .collect() + } + + pub(crate) fn transcript_overlay_live_tail_key(&self) -> Option { + if !self.transcript_overlay_has_live_tail() { + return None; + } + + let active_cell = self.active_cell.as_ref(); + Some(ActiveCellTranscriptKey { + revision: self.active_cell_revision, + is_stream_continuation: active_cell.is_some_and(|cell| cell.is_stream_continuation()), + animation_tick: active_cell.and_then(|cell| cell.transcript_animation_tick()), + }) + } + + pub(crate) fn transcript_overlay_live_tail_lines( + &self, + width: u16, + ) -> Option>> { + self.transcript_overlay_has_live_tail() + .then(|| self.live_transcript_lines(width.max(1))) + } + pub(crate) fn transcript_overlay_lines(&self, width: u16) -> Vec> { let width = width.max(1); let mut lines = Vec::new(); diff --git a/crates/tui/src/host_overlay.rs b/crates/tui/src/host_overlay.rs index a66f3b9..e8aa777 100644 --- a/crates/tui/src/host_overlay.rs +++ b/crates/tui/src/host_overlay.rs @@ -4,10 +4,10 @@ use anyhow::Result; use devo_utils::ansi_escape::ansi_escape_line; use ratatui::style::Stylize; use ratatui::text::Line; -use std::time::Duration; use crate::chatwidget::ChatWidget; use crate::pager_overlay::Overlay; +use crate::pager_overlay::TranscriptOverlay; use crate::tui::Tui; use crate::tui::TuiEvent; @@ -31,9 +31,10 @@ impl OverlayState { return Ok(()); }; - if matches!(tui_event, TuiEvent::Draw) { - let width = tui.terminal.size()?.width.max(1); - overlay.set_transcript_lines(chat_widget.transcript_overlay_lines(width)); + if matches!(tui_event, TuiEvent::Draw) + && let Overlay::Transcript(transcript) = overlay + { + sync_transcript_overlay(transcript, tui, chat_widget)?; } overlay.handle_event(tui, tui_event)?; @@ -41,9 +42,14 @@ impl OverlayState { self.overlay = None; tui.leave_alt_screen()?; tui.frame_requester().schedule_frame(); - } else if chat_widget.transcript_overlay_has_live_tail() { + } else if let Overlay::Transcript(transcript) = overlay + && transcript.is_scrolled_to_bottom() + && chat_widget + .transcript_overlay_live_tail_key() + .is_some_and(|key| key.animation_tick.is_some()) + { tui.frame_requester() - .schedule_frame_in(Duration::from_millis(50)); + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } Ok(()) @@ -57,7 +63,8 @@ impl OverlayState { let width = tui.terminal.size()?.width.max(1); tui.enter_alt_screen()?; self.overlay = Some(Overlay::new_transcript( - chat_widget.transcript_overlay_lines(width), + chat_widget.transcript_overlay_cells(width), + width, )); tui.frame_requester().schedule_frame(); Ok(()) @@ -80,6 +87,23 @@ impl OverlayState { } } +fn sync_transcript_overlay( + transcript: &mut TranscriptOverlay, + tui: &mut Tui, + chat_widget: &ChatWidget, +) -> Result<()> { + let width = tui.terminal.size()?.width.max(1); + let cell_count = chat_widget.transcript_overlay_cell_count(); + if transcript.needs_committed_cells_sync(width, cell_count) { + transcript.replace_committed_cells(width, chat_widget.transcript_overlay_cells(width)); + } + let live_tail_key = chat_widget.transcript_overlay_live_tail_key(); + transcript.sync_live_tail(width, live_tail_key, |tail_width| { + chat_widget.transcript_overlay_live_tail_lines(tail_width) + }); + Ok(()) +} + fn diff_overlay_lines(text: &str) -> Vec> { if text.trim().is_empty() { vec!["No changes detected.".italic().into()] diff --git a/crates/tui/src/pager_overlay.rs b/crates/tui/src/pager_overlay.rs index f07e86c..d851f8f 100644 --- a/crates/tui/src/pager_overlay.rs +++ b/crates/tui/src/pager_overlay.rs @@ -1,8 +1,10 @@ //! Pager-style overlays rendered in the terminal alternate screen. //! -//! These overlays are intentionally small: they own full-screen scrolling UI, -//! while the host owns when alternate screen mode is entered and left. +//! These overlays own the full-screen scrolling UI, while the host owns when +//! alternate screen mode is entered and left. +use std::cell::Cell; +use std::fmt; use std::io::Result; use crossterm::event::KeyCode; @@ -10,6 +12,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; +use ratatui::buffer::Cell as BufferCell; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; @@ -21,6 +24,11 @@ use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use crate::chatwidget::ActiveCellTranscriptKey; +use crate::chatwidget::TranscriptOverlayCell; +use crate::render::Insets; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; use crate::tui; use crate::tui::TuiEvent; @@ -29,8 +37,8 @@ pub(crate) enum Overlay { Static(StaticOverlay), } -impl std::fmt::Debug for Overlay { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Debug for Overlay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Transcript(_) => f.write_str("Overlay::Transcript"), Self::Static(_) => f.write_str("Overlay::Static"), @@ -39,8 +47,8 @@ impl std::fmt::Debug for Overlay { } impl Overlay { - pub(crate) fn new_transcript(lines: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(lines)) + pub(crate) fn new_transcript(cells: Vec, width: u16) -> Self { + Self::Transcript(TranscriptOverlay::new(cells, width)) } pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { @@ -60,27 +68,32 @@ impl Overlay { Overlay::Static(overlay) => overlay.is_done(), } } - - pub(crate) fn set_transcript_lines(&mut self, lines: Vec>) { - if let Overlay::Transcript(overlay) = self { - overlay.set_lines(lines); - } - } } -#[derive(Debug)] struct PagerView { - lines: Vec>, + renderables: Vec>, scroll_offset: usize, title: String, last_content_height: Option, last_rendered_height: Option, } +impl fmt::Debug for PagerView { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PagerView") + .field("renderables", &self.renderables.len()) + .field("scroll_offset", &self.scroll_offset) + .field("title", &self.title) + .field("last_content_height", &self.last_content_height) + .field("last_rendered_height", &self.last_rendered_height) + .finish() + } +} + impl PagerView { - fn new(lines: Vec>, title: String, scroll_offset: usize) -> Self { + fn new(renderables: Vec>, title: String, scroll_offset: usize) -> Self { Self { - lines, + renderables, scroll_offset, title, last_content_height: None, @@ -88,18 +101,11 @@ impl PagerView { } } - fn set_lines(&mut self, lines: Vec>) { - let follow_bottom = self.is_scrolled_to_bottom(); - self.lines = lines; - if follow_bottom { - self.scroll_offset = usize::MAX; - } - } - fn content_height(&self, width: u16) -> usize { - Paragraph::new(Text::from(self.lines.clone())) - .wrap(Wrap { trim: false }) - .line_count(width.max(1)) + self.renderables + .iter() + .map(|renderable| renderable.desired_height(width.max(1)) as usize) + .sum() } fn render(&mut self, area: Rect, buf: &mut Buffer) { @@ -111,17 +117,13 @@ impl PagerView { self.render_header(area, buf); let content_area = self.content_area(area); self.last_content_height = Some(content_area.height as usize); - let content_height = self.content_height(content_area.width.max(1)); + let content_height = self.content_height(content_area.width); self.last_rendered_height = Some(content_height); let max_scroll = content_height.saturating_sub(content_area.height as usize); self.scroll_offset = self.scroll_offset.min(max_scroll); - Paragraph::new(Text::from(self.lines.clone())) - .wrap(Wrap { trim: false }) - .scroll((u16::try_from(self.scroll_offset).unwrap_or(u16::MAX), 0)) - .render(content_area, buf); - + self.render_content(content_area, buf); self.render_bottom_bar(area, content_area, buf, content_height); } @@ -135,6 +137,50 @@ impl PagerView { .render_ref(Rect::new(area.x, area.y, area.width, 1), buf); } + fn render_content(&self, area: Rect, buf: &mut Buffer) { + let mut y = area.y as isize - self.scroll_offset as isize; + let content_top = area.y as isize; + let content_bottom = area.bottom() as isize; + let mut drawn_bottom = area.y; + + for renderable in &self.renderables { + let top = y; + let height = renderable.desired_height(area.width.max(1)) as isize; + y += height; + let bottom = y; + + if bottom <= content_top { + continue; + } + if top >= content_bottom { + break; + } + + if top < content_top { + let offset = u16::try_from(content_top.saturating_sub(top)).unwrap_or(u16::MAX); + let drawn = render_offset_content(area, buf, &**renderable, offset); + drawn_bottom = drawn_bottom.max(area.y.saturating_add(drawn)); + } else { + let draw_y = u16::try_from(top).unwrap_or(u16::MAX); + let draw_bottom = bottom.min(content_bottom); + let draw_height = u16::try_from(draw_bottom.saturating_sub(top)).unwrap_or(0); + let draw_area = Rect::new(area.x, draw_y, area.width, draw_height); + renderable.render(draw_area, buf); + drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height)); + } + } + + for row in drawn_bottom..area.bottom() { + if area.width == 0 { + break; + } + buf[(area.x, row)] = BufferCell::from('~'); + for col in area.x.saturating_add(1)..area.right() { + buf[(col, row)] = BufferCell::from(' '); + } + } + } + fn render_bottom_bar( &self, full_area: Rect, @@ -269,22 +315,190 @@ impl PagerView { } } -#[derive(Debug)] +struct CachedRenderable { + renderable: Box, + height: Cell>, + last_width: Cell>, +} + +impl CachedRenderable { + fn new(renderable: impl Into>) -> Self { + Self { + renderable: renderable.into(), + height: Cell::new(None), + last_width: Cell::new(None), + } + } +} + +impl Renderable for CachedRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.renderable.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let width = width.max(1); + if self.last_width.get() != Some(width) { + self.height.set(Some(self.renderable.desired_height(width))); + self.last_width.set(Some(width)); + } + self.height.get().unwrap_or(0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CommittedCellsKey { + width: u16, + cell_count: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LiveTailKey { + width: u16, + revision: u64, + is_stream_continuation: bool, + animation_tick: Option, +} + pub(crate) struct TranscriptOverlay { view: PagerView, + cells: Vec, + committed_key: CommittedCellsKey, + live_tail: Option, + live_tail_key: Option, is_done: bool, } +impl fmt::Debug for TranscriptOverlay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TranscriptOverlay") + .field("view", &self.view) + .field("cells", &self.cells.len()) + .field("committed_key", &self.committed_key) + .field("live_tail", &self.live_tail.is_some()) + .field("live_tail_key", &self.live_tail_key) + .field("is_done", &self.is_done) + .finish() + } +} + impl TranscriptOverlay { - fn new(lines: Vec>) -> Self { + fn new(cells: Vec, width: u16) -> Self { + let committed_key = CommittedCellsKey { + width: width.max(1), + cell_count: cells.len(), + }; Self { - view: PagerView::new(lines, "T R A N S C R I P T".to_string(), usize::MAX), + view: PagerView::new( + Self::render_cells(&cells, None), + "T R A N S C R I P T".to_string(), + usize::MAX, + ), + cells, + committed_key, + live_tail: None, + live_tail_key: None, is_done: false, } } - fn set_lines(&mut self, lines: Vec>) { - self.view.set_lines(lines); + pub(crate) fn needs_committed_cells_sync(&self, width: u16, cell_count: usize) -> bool { + self.committed_key + != (CommittedCellsKey { + width: width.max(1), + cell_count, + }) + } + + pub(crate) fn replace_committed_cells( + &mut self, + width: u16, + cells: Vec, + ) { + let next_key = CommittedCellsKey { + width: width.max(1), + cell_count: cells.len(), + }; + if self.committed_key == next_key { + return; + } + + let follow_bottom = self.view.is_scrolled_to_bottom(); + self.cells = cells; + self.committed_key = next_key; + self.rebuild_renderables(); + if follow_bottom { + self.view.scroll_offset = usize::MAX; + } + } + + pub(crate) fn sync_live_tail( + &mut self, + width: u16, + active_key: Option, + compute_lines: impl FnOnce(u16) -> Option>>, + ) -> bool { + let next_key = active_key.map(|key| LiveTailKey { + width: width.max(1), + revision: key.revision, + is_stream_continuation: key.is_stream_continuation, + animation_tick: key.animation_tick, + }); + + if self.live_tail_key == next_key { + return false; + } + + let follow_bottom = self.view.is_scrolled_to_bottom(); + self.live_tail_key = next_key; + self.live_tail = next_key.and_then(|key| { + let lines = compute_lines(key.width).unwrap_or_default(); + (!lines.is_empty()).then_some(TranscriptOverlayCell { + lines, + is_stream_continuation: key.is_stream_continuation, + }) + }); + self.rebuild_renderables(); + if follow_bottom { + self.view.scroll_offset = usize::MAX; + } + true + } + + pub(crate) fn is_scrolled_to_bottom(&self) -> bool { + self.view.is_scrolled_to_bottom() + } + + fn rebuild_renderables(&mut self) { + self.view.renderables = Self::render_cells(&self.cells, self.live_tail.as_ref()); + } + + fn render_cells( + cells: &[TranscriptOverlayCell], + live_tail: Option<&TranscriptOverlayCell>, + ) -> Vec> { + let mut renderables = Vec::new(); + for cell in cells { + renderables.push(Self::cell_renderable(cell.clone(), !renderables.is_empty())); + } + if let Some(tail) = live_tail { + renderables.push(Self::cell_renderable(tail.clone(), !renderables.is_empty())); + } + renderables + } + + fn cell_renderable(cell: TranscriptOverlayCell, has_prior_cells: bool) -> Box { + let paragraph = Paragraph::new(Text::from(cell.lines)).wrap(Wrap { trim: false }); + let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); + if has_prior_cells && !cell.is_stream_continuation { + renderable = Box::new(InsetRenderable::new( + renderable, + Insets::tlbr( + /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + ), + )); + } + renderable } fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { @@ -315,16 +529,24 @@ impl TranscriptOverlay { } } -#[derive(Debug)] pub(crate) struct StaticOverlay { view: PagerView, is_done: bool, } +impl fmt::Debug for StaticOverlay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StaticOverlay") + .field("view", &self.view) + .field("is_done", &self.is_done) + .finish() + } +} + impl StaticOverlay { fn new(lines: Vec>, title: String) -> Self { Self { - view: PagerView::new(lines, title, 0), + view: PagerView::new(vec![lines_renderable(lines)], title, 0), is_done: false, } } @@ -357,6 +579,37 @@ impl StaticOverlay { } } +fn lines_renderable(lines: Vec>) -> Box { + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + Box::new(CachedRenderable::new(paragraph)) +} + +fn render_offset_content( + area: Rect, + buf: &mut Buffer, + renderable: &dyn Renderable, + scroll_offset: u16, +) -> u16 { + let height = renderable.desired_height(area.width.max(1)); + let mut tall_buf = Buffer::empty(Rect::new( + 0, + 0, + area.width, + height.min(area.height.saturating_add(scroll_offset)), + )); + renderable.render(*tall_buf.area(), &mut tall_buf); + let copy_height = area + .height + .min(tall_buf.area().height.saturating_sub(scroll_offset)); + for y in 0..copy_height { + let src_y = y.saturating_add(scroll_offset); + for x in 0..area.width { + buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone(); + } + } + copy_height +} + fn is_press_or_repeat(key_event: KeyEvent) -> bool { matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) } @@ -390,6 +643,21 @@ mod tests { .join("\n") } + fn transcript_cell(text: impl Into) -> TranscriptOverlayCell { + TranscriptOverlayCell { + lines: vec![Line::from(text.into())], + is_stream_continuation: false, + } + } + + fn active_key(revision: u64) -> ActiveCellTranscriptKey { + ActiveCellTranscriptKey { + revision, + is_stream_continuation: false, + animation_tick: None, + } + } + #[test] fn static_overlay_renders_title_content_and_percent() { let mut overlay = StaticOverlay::new( @@ -410,9 +678,11 @@ mod tests { #[test] fn pager_scrolls_down_and_back_up() { let mut view = PagerView::new( - (0..20) - .map(|index| Line::from(format!("line {index}"))) - .collect(), + vec![lines_renderable( + (0..20) + .map(|index| Line::from(format!("line {index}"))) + .collect(), + )], "T E S T".to_string(), 0, ); @@ -432,7 +702,7 @@ mod tests { #[test] fn transcript_overlay_closes_with_ctrl_t() { - let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]); + let mut overlay = TranscriptOverlay::new(vec![transcript_cell("hello")], 80); let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); assert!(ctrl_t_key(key)); @@ -440,4 +710,46 @@ mod tests { assert!(overlay.is_done()); } + + #[test] + fn unchanged_live_tail_key_does_not_recompute_tail() { + let mut overlay = TranscriptOverlay::new(Vec::new(), 40); + let calls = Cell::new(0); + let key = active_key(7); + + assert!(overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("tail")]) + })); + assert!(!overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("changed tail")]) + })); + + assert_eq!(1, calls.get()); + } + + #[test] + fn unchanged_live_tail_key_preserves_manual_scroll_position() { + let cells = (0..30) + .map(|index| transcript_cell(format!("line {index}"))) + .collect(); + let mut overlay = TranscriptOverlay::new(cells, 30); + let area = Rect::new(0, 0, 30, 6); + let mut buf = Buffer::empty(area); + overlay.view.render(area, &mut buf); + overlay.view.scroll_offset = 5; + + assert!(overlay.sync_live_tail(30, Some(active_key(1)), |_| { + Some(vec![Line::from("live tail")]) + })); + assert_eq!(5, overlay.view.scroll_offset); + + overlay.view.scroll_offset = 4; + assert!(!overlay.sync_live_tail(30, Some(active_key(1)), |_| { + Some(vec![Line::from("new live tail")]) + })); + + assert_eq!(4, overlay.view.scroll_offset); + } } From 713f077ae59b011b466495cc530aa2e132df80e8 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 14 May 2026 13:55:18 +0800 Subject: [PATCH 2/4] Remove the tool old ToolOutput trait --- crates/tools/README.md | 2 +- crates/tools/src/apply_patch.rs | 46 +++---- crates/tools/src/handlers/apply_patch.rs | 4 +- crates/tools/src/handlers/bash.rs | 4 +- crates/tools/src/handlers/exec_command.rs | 4 +- crates/tools/src/handlers/read.rs | 4 +- crates/tools/src/handlers/shell_command.rs | 4 +- crates/tools/src/invocation.rs | 66 ++++------ crates/tools/src/lib.rs | 7 +- crates/tools/src/read.rs | 92 +++++++++----- crates/tools/src/shell_exec.rs | 135 ++++++++++++++------- crates/tools/src/tool.rs | 63 ---------- docs/spec-tools.md | 4 + 13 files changed, 215 insertions(+), 220 deletions(-) delete mode 100644 crates/tools/src/tool.rs diff --git a/crates/tools/README.md b/crates/tools/README.md index b673db2..13100c0 100644 --- a/crates/tools/README.md +++ b/crates/tools/README.md @@ -477,7 +477,7 @@ Here is architecture Notes - This section describes the intended improved tool architecture, not a strict mirror of the current implementation. -- Both the older `Tool` / `ToolRegistry` / `ToolOutput` path and the current `spec.rs` path should be treated as transitional. +- Tool execution output is standardized on `invocation::ToolOutput`, implemented by `FunctionToolOutput`; structured metadata is carried through `ToolContent::Mixed`. - The improved design may intentionally differ from `crates/tools/src/spec.rs` where the current shapes are too limited or too implementation-driven. - Streaming assembly stays in the adapter layer. Adapters accumulate provider deltas and only emit fully formed tool invocations into the runtime. - The runtime should not model partial tool calls. diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index 46141c6..545c3aa 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -6,13 +6,13 @@ use serde_json::json; use tokio::fs; use tracing::debug; -use crate::ToolOutput; +use crate::FunctionToolOutput; pub(crate) async fn exec_apply_patch( cwd: &std::path::Path, session_id: &str, input: serde_json::Value, -) -> anyhow::Result { +) -> anyhow::Result { let patch_text = input["patchText"].as_str().unwrap_or(""); debug!( tool = "apply_patch", @@ -25,7 +25,7 @@ pub(crate) async fn exec_apply_patch( ); if patch_text.trim().is_empty() { debug!("rejecting apply_patch request because patchText is empty"); - return Ok(ToolOutput::error("patchText is required")); + return Ok(FunctionToolOutput::error("patchText is required")); } let patch = parse_patch(patch_text)?; @@ -38,10 +38,10 @@ pub(crate) async fn exec_apply_patch( .to_string(); if normalized == "*** Begin Patch\n*** End Patch" { debug!("rejecting apply_patch request because patch contained no changes"); - return Ok(ToolOutput::error("patch rejected: empty patch")); + return Ok(FunctionToolOutput::error("patch rejected: empty patch")); } debug!("rejecting apply_patch request because no hunks were found"); - return Ok(ToolOutput::error( + return Ok(FunctionToolOutput::error( "apply_patch verification failed: no hunks found", )); } @@ -124,18 +124,17 @@ pub(crate) async fn exec_apply_patch( summary = ?summary, "apply_patch completed successfully" ); - Ok(ToolOutput { - content: format!( + Ok(FunctionToolOutput::success_with_metadata( + format!( "Success. Updated the following files:\n{}", summary.join("\n") ), - is_error: false, - metadata: Some(json!({ + json!({ "diff": total_diff, "files": files, "diagnostics": {}, - })), - }) + }), + )) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -710,6 +709,8 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; + use crate::ToolContent; + use super::HunkLine; use super::PatchHunk; use super::PatchKind; @@ -940,15 +941,12 @@ hello .expect("execute apply_patch"); assert!(!output.is_error); - assert!( - output - .content - .contains("Success. Updated the following files:") - ); - assert!(output.content.contains("A add.txt")); - assert!(output.content.contains("M update.txt")); - assert!(output.content.contains("D delete.txt")); - assert!(output.content.contains("M moved/to.txt")); + let text = output.content.text_part().expect("text content"); + assert!(text.contains("Success. Updated the following files:")); + assert!(text.contains("A add.txt")); + assert!(text.contains("M update.txt")); + assert!(text.contains("D delete.txt")); + assert!(text.contains("M moved/to.txt")); assert_eq!( std::fs::read_to_string(cwd.join("add.txt")).expect("read added file"), @@ -965,7 +963,13 @@ hello "after\n" ); - let metadata = output.metadata.expect("metadata"); + let ToolContent::Mixed { + json: Some(metadata), + .. + } = &output.content + else { + panic!("expected mixed output metadata, got {:?}", output.content); + }; let files = metadata["files"].as_array().expect("files metadata"); assert_eq!(files.len(), 4); assert_eq!(files[0]["additions"], 1); diff --git a/crates/tools/src/handlers/apply_patch.rs b/crates/tools/src/handlers/apply_patch.rs index c5a63b4..4ecdf36 100644 --- a/crates/tools/src/handlers/apply_patch.rs +++ b/crates/tools/src/handlers/apply_patch.rs @@ -4,7 +4,7 @@ use crate::apply_patch::exec_apply_patch; use crate::errors::ToolExecutionError; use crate::events::ToolProgressSender; use crate::handler_kind::ToolHandlerKind; -use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; +use crate::invocation::{ToolInvocation, ToolOutput}; use crate::tool_handler::ToolHandler; pub struct ApplyPatchHandler; @@ -25,6 +25,6 @@ impl ToolHandler for ApplyPatchHandler { .map_err(|e| ToolExecutionError::ExecutionFailed { message: e.to_string(), })?; - Ok(Box::new(FunctionToolOutput::from_output(output))) + Ok(Box::new(output)) } } diff --git a/crates/tools/src/handlers/bash.rs b/crates/tools/src/handlers/bash.rs index f5b5f58..944160f 100644 --- a/crates/tools/src/handlers/bash.rs +++ b/crates/tools/src/handlers/bash.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use crate::errors::ToolExecutionError; use crate::events::ToolProgressSender; use crate::handler_kind::ToolHandlerKind; -use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; +use crate::invocation::{ToolInvocation, ToolOutput}; use crate::shell_exec::{ ShellExecRequest, default_max_output_tokens, default_timeout_ms, default_yield_time_ms, execute_shell_command, @@ -75,6 +75,6 @@ impl ToolHandler for BashHandler { message: e.to_string(), })?; - Ok(Box::new(FunctionToolOutput::from_output(output))) + Ok(Box::new(output)) } } diff --git a/crates/tools/src/handlers/exec_command.rs b/crates/tools/src/handlers/exec_command.rs index 864b14e..7d378c4 100644 --- a/crates/tools/src/handlers/exec_command.rs +++ b/crates/tools/src/handlers/exec_command.rs @@ -95,7 +95,9 @@ impl ToolHandler for ExecCommandHandler { .map_err(|e| ToolExecutionError::ExecutionFailed { message: e.to_string(), })?; - let content = format_apply_patch_intercept_response(&output.content); + let content = format_apply_patch_intercept_response( + output.content.text_part().unwrap_or_default(), + ); let output = if output.is_error { FunctionToolOutput::error(content) } else { diff --git a/crates/tools/src/handlers/read.rs b/crates/tools/src/handlers/read.rs index 266cff8..dc9373f 100644 --- a/crates/tools/src/handlers/read.rs +++ b/crates/tools/src/handlers/read.rs @@ -55,7 +55,7 @@ impl ToolHandler for ReadHandler { let output = output.map_err(|e| ToolExecutionError::ExecutionFailed { message: format!("{}", e), })?; - return Ok(Box::new(FunctionToolOutput::from_output(output))); + return Ok(Box::new(output)); } let is_bin = is_binary_file(&path); @@ -73,6 +73,6 @@ impl ToolHandler for ReadHandler { let output = output.map_err(|e| ToolExecutionError::ExecutionFailed { message: format!("{}", e), })?; - Ok(Box::new(FunctionToolOutput::from_output(output))) + Ok(Box::new(output)) } } diff --git a/crates/tools/src/handlers/shell_command.rs b/crates/tools/src/handlers/shell_command.rs index c2ed66a..1150f75 100644 --- a/crates/tools/src/handlers/shell_command.rs +++ b/crates/tools/src/handlers/shell_command.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use crate::errors::ToolExecutionError; use crate::events::ToolProgressSender; use crate::handler_kind::ToolHandlerKind; -use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; +use crate::invocation::{ToolInvocation, ToolOutput}; use crate::shell_exec::{ ShellExecRequest, default_max_output_tokens, default_timeout_ms, default_yield_time_ms, execute_shell_command, @@ -66,6 +66,6 @@ impl ToolHandler for ShellCommandHandler { message: e.to_string(), })?; - Ok(Box::new(FunctionToolOutput::from_output(output))) + Ok(Box::new(output)) } } diff --git a/crates/tools/src/invocation.rs b/crates/tools/src/invocation.rs index 9e09258..bfa666d 100644 --- a/crates/tools/src/invocation.rs +++ b/crates/tools/src/invocation.rs @@ -36,6 +36,14 @@ pub enum ToolContent { } impl ToolContent { + pub fn text_part(&self) -> Option<&str> { + match self { + ToolContent::Text(text) => Some(text), + ToolContent::Json(_) => None, + ToolContent::Mixed { text, .. } => text.as_deref(), + } + } + pub fn into_string(self) -> String { match self { ToolContent::Text(t) => t, @@ -74,20 +82,13 @@ impl FunctionToolOutput { } } - pub fn from_output(output: crate::ToolOutput) -> Self { + pub fn success_with_metadata(content: impl Into, metadata: serde_json::Value) -> Self { FunctionToolOutput { - content: if output.is_error { - ToolContent::Text(output.content) - } else { - match output.metadata { - Some(meta) => ToolContent::Mixed { - text: Some(output.content), - json: Some(meta), - }, - None => ToolContent::Text(output.content), - } + content: ToolContent::Mixed { + text: Some(content.into()), + json: Some(metadata), }, - is_error: output.is_error, + is_error: false, } } } @@ -105,6 +106,7 @@ impl ToolOutput for FunctionToolOutput { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn tool_name_newtype() { @@ -125,12 +127,14 @@ mod tests { #[test] fn tool_content_text() { let c = ToolContent::Text("hello".into()); + assert_eq!(c.text_part(), Some("hello")); assert_eq!(c.into_string(), "hello"); } #[test] fn tool_content_json() { let c = ToolContent::Json(serde_json::json!({"key": "val"})); + assert_eq!(c.text_part(), None); assert!(c.into_string().contains("val")); } @@ -140,6 +144,7 @@ mod tests { text: Some("text".into()), json: Some(serde_json::json!({"key": 1})), }; + assert_eq!(c.text_part(), Some("text")); let s = c.into_string(); assert!(s.contains("text")); assert!(s.contains("key")); @@ -160,6 +165,7 @@ mod tests { text: None, json: Some(serde_json::json!(42)), }; + assert_eq!(c.text_part(), None); assert_eq!(c.into_string(), "42"); } @@ -178,47 +184,19 @@ mod tests { } #[test] - fn function_tool_output_from_legacy_success_no_meta() { - let legacy = crate::ToolOutput { - content: "ok".into(), - is_error: false, - metadata: None, - }; - let out = FunctionToolOutput::from_output(legacy); - assert!(!out.is_error); - assert!(matches!(out.content, ToolContent::Text(ref t) if t == "ok")); - } - - #[test] - fn function_tool_output_from_legacy_success_with_meta() { - let legacy = crate::ToolOutput { - content: "result".into(), - is_error: false, - metadata: Some(serde_json::json!({"key": "val"})), - }; - let out = FunctionToolOutput::from_output(legacy); + fn function_tool_output_success_with_metadata() { + let out = + FunctionToolOutput::success_with_metadata("result", serde_json::json!({"key": "val"})); assert!(!out.is_error); match out.content { ToolContent::Mixed { text, json } => { assert_eq!(text, Some("result".into())); - assert!(json.is_some()); + assert_eq!(json, Some(serde_json::json!({"key": "val"}))); } _ => panic!("expected Mixed"), } } - #[test] - fn function_tool_output_from_legacy_error() { - let legacy = crate::ToolOutput { - content: "err".into(), - is_error: true, - metadata: None, - }; - let out = FunctionToolOutput::from_output(legacy); - assert!(out.is_error); - assert!(matches!(out.content, ToolContent::Text(ref t) if t == "err")); - } - #[test] fn tool_output_trait_impl() { let out = Box::new(FunctionToolOutput::success("trait test")); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2a00f02..db817fa 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -17,13 +17,14 @@ pub mod unified_exec; mod apply_patch; mod read; mod shell_exec; -mod tool; // New re-exports pub use errors::*; pub use events::*; pub use handler_kind::ToolHandlerKind; -pub use invocation::{FunctionToolOutput, ToolCallId, ToolContent, ToolInvocation, ToolName}; +pub use invocation::{ + FunctionToolOutput, ToolCallId, ToolContent, ToolInvocation, ToolName, ToolOutput, +}; pub use json_schema::JsonSchema; pub use registry::*; pub use registry_plan::*; @@ -31,8 +32,6 @@ pub use router::*; pub use tool_handler::ToolHandler; pub use tool_spec::*; -pub use tool::ToolOutput; - /// Create a fully-configured tool registry with all built-in tools. /// This is the recommended way to bootstrap tools. pub fn create_default_tool_registry() -> registry::ToolRegistry { diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index 28b956f..596c2d3 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -5,13 +5,13 @@ use std::io::BufReader; use std::io::Read; use std::path::Path; -use crate::ToolOutput; +use crate::FunctionToolOutput; pub(crate) fn read_directory( path: &Path, limit: usize, offset: usize, -) -> anyhow::Result { +) -> anyhow::Result { let mut items = std::fs::read_dir(path)? .flatten() .map(|entry| { @@ -56,18 +56,21 @@ pub(crate) fn read_directory( ] .join("\n"); - Ok(ToolOutput { - content: output, - is_error: false, - metadata: Some(json!({ + Ok(FunctionToolOutput::success_with_metadata( + output, + json!({ "preview": preview, "truncated": truncated, "loaded": [] - })), - }) + }), + )) } -pub(crate) fn read_file(path: &Path, limit: usize, offset: usize) -> anyhow::Result { +pub(crate) fn read_file( + path: &Path, + limit: usize, + offset: usize, +) -> anyhow::Result { let file = File::open(path)?; let reader = BufReader::new(file); let start = offset.saturating_sub(1); @@ -87,6 +90,7 @@ pub(crate) fn read_file(path: &Path, limit: usize, offset: usize) -> anyhow::Res more = true; continue; } + // TODO: check the truncate policy if line.len() > 2000 { line.truncate(2000); line.push_str("... (line truncated to 2000 chars)"); @@ -102,7 +106,7 @@ pub(crate) fn read_file(path: &Path, limit: usize, offset: usize) -> anyhow::Res } if count < offset && !(count == 0 && offset == 1) { - return Ok(ToolOutput::error(format!( + return Ok(FunctionToolOutput::error(format!( "Offset {} is out of range for this file ({} lines)", offset, count ))); @@ -133,15 +137,14 @@ pub(crate) fn read_file(path: &Path, limit: usize, offset: usize) -> anyhow::Res } output.push_str("\n"); - Ok(ToolOutput { - content: output, - is_error: false, - metadata: Some(json!({ + Ok(FunctionToolOutput::success_with_metadata( + output, + json!({ "preview": raw.iter().take(20).cloned().collect::>().join("\n"), "truncated": cut || more, "loaded": [] - })), - }) + }), + )) } pub(crate) fn is_binary_file(path: &Path) -> anyhow::Result { @@ -249,6 +252,8 @@ pub(crate) fn missing_file_message(filepath: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::ToolContent; + use pretty_assertions::assert_eq; use std::env; use std::fs::File; use std::fs::{self}; @@ -277,6 +282,20 @@ mod tests { } } + fn output_text(output: &FunctionToolOutput) -> &str { + output.content.text_part().expect("text content") + } + + fn output_metadata(output: &FunctionToolOutput) -> &serde_json::Value { + match &output.content { + ToolContent::Mixed { + json: Some(metadata), + .. + } => metadata, + content => panic!("expected mixed output metadata, got {content:?}"), + } + } + #[test] fn read_directory_sorts_entries_and_reports_truncation() { let dir = create_temp_dir("dir"); @@ -285,16 +304,21 @@ mod tests { fs::create_dir_all(dir.join("subdir")).unwrap(); let output = read_directory(&dir, 1, 2).unwrap(); - assert!(output.content.contains("directory")); - assert!(output.content.contains("b.txt")); + let text = output_text(&output); + assert!(text.contains("directory")); + assert!(text.contains("b.txt")); assert!( - output.content.contains( + text.contains( "(Showing 1 of 3 entries. Use 'offset' parameter to read beyond entry 3)" ) ); - let metadata = output.metadata.unwrap(); - assert!(metadata.get("truncated").and_then(|value| value.as_bool()) == Some(true)); + assert_eq!( + output_metadata(&output) + .get("truncated") + .and_then(|value| value.as_bool()), + Some(true) + ); } #[test] @@ -305,16 +329,17 @@ mod tests { let output = read_file(&path, 2, 2).unwrap(); assert!(!output.is_error); - assert!(output.content.contains("2: line2")); - assert!(output.content.contains("3: line3")); - assert!( - output - .content - .contains("(Showing lines 2-3 of 5. Use offset=4 to continue.)") + let text = output_text(&output); + assert!(text.contains("2: line2")); + assert!(text.contains("3: line3")); + assert!(text.contains("(Showing lines 2-3 of 5. Use offset=4 to continue.)")); + + assert_eq!( + output_metadata(&output) + .get("truncated") + .and_then(|value| value.as_bool()), + Some(true) ); - - let metadata = output.metadata.unwrap(); - assert!(metadata.get("truncated").and_then(|value| value.as_bool()) == Some(true)); } #[test] @@ -325,7 +350,12 @@ mod tests { let output = read_file(&path, 10, 5).unwrap(); assert!(output.is_error); - assert!(output.content.contains("Offset 5 is out of range")); + assert!( + output + .content + .text_part() + .is_some_and(|text| text.contains("Offset 5 is out of range")) + ); } #[test] diff --git a/crates/tools/src/shell_exec.rs b/crates/tools/src/shell_exec.rs index e070362..e64be6d 100644 --- a/crates/tools/src/shell_exec.rs +++ b/crates/tools/src/shell_exec.rs @@ -8,7 +8,7 @@ use tokio::process::Command; use tokio::time::{Duration, timeout}; use tracing::info; -use crate::ToolOutput; +use crate::FunctionToolOutput; use crate::events::ToolProgressSender; const MAX_METADATA_LENGTH: usize = 30_000; @@ -83,7 +83,7 @@ Examples of valid command strings: pub(crate) async fn execute_shell_command( request: ShellExecRequest, progress: Option, -) -> anyhow::Result { +) -> anyhow::Result { let ShellExecRequest { command, workdir, @@ -97,7 +97,7 @@ pub(crate) async fn execute_shell_command( } = request; if !workdir.exists() { - return Ok(ToolOutput::error(format!( + return Ok(FunctionToolOutput::error(format!( "working directory does not exist: {}", workdir.display() ))); @@ -163,38 +163,28 @@ pub(crate) async fn execute_shell_command( } let result_text = truncate_output(&result_text, max_output_tokens); if output.status.success() { - Ok(ToolOutput { - content: result_text.clone(), - is_error: false, - metadata: Some(json!({ + Ok(FunctionToolOutput::success_with_metadata( + result_text.clone(), + json!({ "output": preview(&result_text), "command": command_preview, "exit": output.status.code(), "description": description, "cwd": workdir, "yield_time_ms": yield_time_ms, - })), - }) + }), + )) } else { let code = output.status.code().unwrap_or(-1); - Ok(ToolOutput { - content: format!("exit code {code}\n{result_text}"), - is_error: true, - metadata: Some(json!({ - "output": preview(&result_text), - "command": command_preview, - "exit": code, - "description": description, - "cwd": workdir, - "yield_time_ms": yield_time_ms, - })), - }) + Ok(FunctionToolOutput::error(format!( + "exit code {code}\n{result_text}" + ))) } } - Ok(Err(error)) => Ok(ToolOutput::error(format!( + Ok(Err(error)) => Ok(FunctionToolOutput::error(format!( "failed to spawn process: {error}" ))), - Err(_) => Ok(ToolOutput::error(format!( + Err(_) => Ok(FunctionToolOutput::error(format!( "command timed out after {timeout_ms}ms" ))), } @@ -304,7 +294,7 @@ fn platform_shell(login: bool) -> ShellSpec { async fn run_with_pty( config: PtyRunConfig, progress: Option, -) -> anyhow::Result { +) -> anyhow::Result { let PtyRunConfig { shell, command_to_run, @@ -402,30 +392,24 @@ async fn run_with_pty( text = truncate_output(&text, max_output_tokens); if timed_out { - return Ok(ToolOutput { - content: format!("command timed out after {timeout_ms}ms\n{text}"), - is_error: true, - metadata: Some(json!({ - "output": preview(&text), - "command": command_to_run, - "exit": exit_code, - "description": description, - "cwd": workdir, - "yield_time_ms": yield_time_ms, - "tty": true, - })), - }); + return Ok(FunctionToolOutput::error(format!( + "command timed out after {timeout_ms}ms\n{text}" + ))); } let is_error = exit_code.unwrap_or(1) != 0; - Ok(ToolOutput { - content: if is_error { - format!("exit code {}\n{}", exit_code.unwrap_or(-1), text) - } else { - text.clone() - }, - is_error, - metadata: Some(json!({ + let content = if is_error { + format!("exit code {}\n{}", exit_code.unwrap_or(-1), text) + } else { + text.clone() + }; + if is_error { + return Ok(FunctionToolOutput::error(content)); + } + + Ok(FunctionToolOutput::success_with_metadata( + content, + json!({ "output": preview(&text), "command": command_to_run, "exit": exit_code, @@ -433,13 +417,15 @@ async fn run_with_pty( "cwd": workdir, "yield_time_ms": yield_time_ms, "tty": true, - })), - }) + }), + )) } #[cfg(test)] mod tests { use super::*; + use crate::ToolContent; + use pretty_assertions::assert_eq; #[tokio::test] async fn execute_shell_command_non_tty_sends_progress() { @@ -490,6 +476,61 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn execute_shell_command_success_metadata_is_mixed() { + let result = execute_shell_command( + ShellExecRequest { + command: "echo metadata_test".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "metadata test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + }, + None, + ) + .await + .expect("execute shell command"); + + assert!(!result.is_error); + match result.content { + ToolContent::Mixed { + text: Some(text), + json: Some(metadata), + } => { + assert!(text.contains("metadata_test")); + assert_eq!(metadata["description"], "metadata test"); + } + content => panic!("expected mixed success output, got {content:?}"), + } + } + + #[tokio::test] + async fn execute_shell_command_error_output_is_text_only() { + let result = execute_shell_command( + ShellExecRequest { + command: "exit 7".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "error test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + }, + None, + ) + .await + .expect("execute shell command"); + + assert!(result.is_error); + assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); + } + use super::{merge_streams, platform_shell_program, preview, resolve_shell, truncate_output}; #[test] diff --git a/crates/tools/src/tool.rs b/crates/tools/src/tool.rs deleted file mode 100644 index 53f1452..0000000 --- a/crates/tools/src/tool.rs +++ /dev/null @@ -1,63 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// The output returned by a tool after execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolOutput { - pub content: String, - pub is_error: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -impl ToolOutput { - pub fn success(content: impl Into) -> Self { - Self { - content: content.into(), - is_error: false, - metadata: None, - } - } - - pub fn error(content: impl Into) -> Self { - Self { - content: content.into(), - is_error: true, - metadata: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_output_success() { - let out = ToolOutput::success("done"); - assert_eq!(out.content, "done"); - assert!(!out.is_error); - assert!(out.metadata.is_none()); - } - - #[test] - fn tool_output_error() { - let out = ToolOutput::error("failed"); - assert_eq!(out.content, "failed"); - assert!(out.is_error); - assert!(out.metadata.is_none()); - } - - #[test] - fn tool_output_serde_roundtrip() { - let out = ToolOutput { - content: "hello".into(), - is_error: false, - metadata: Some(serde_json::json!({"key": "val"})), - }; - let json = serde_json::to_string(&out).unwrap(); - let deserialized: ToolOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.content, "hello"); - assert!(!deserialized.is_error); - assert!(deserialized.metadata.is_some()); - } -} diff --git a/docs/spec-tools.md b/docs/spec-tools.md index bfbd45d..356343c 100644 --- a/docs/spec-tools.md +++ b/docs/spec-tools.md @@ -155,6 +155,10 @@ pub enum ToolContent { } ``` +Current implementation note: tool handlers return `Box` from +`invocation.rs`; `FunctionToolOutput` is the standard concrete implementation, +and legacy text-plus-metadata results should be represented as `ToolContent::Mixed`. + ## Tool Trait Contract ```rust From 9e43c179912f6e6db10019d40dc531c237ff78ec Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 14 May 2026 16:19:41 +0800 Subject: [PATCH 3/4] Update tool transcript handling and new session UI --- README.md | 46 ++++-- crates/core/src/conversation/records.rs | 3 + crates/core/src/query.rs | 191 +++++++++++++++++++++- crates/protocol/src/event.rs | 31 ++++ crates/server/src/persistence.rs | 50 ++++++ crates/server/src/projection.rs | 45 ++++- crates/server/src/runtime/turn_exec.rs | 3 + crates/tools/src/handlers/exec_command.rs | 43 +++++ crates/tools/src/handlers/plan.rs | 1 + crates/tools/src/handlers/webfetch.rs | 2 + crates/tools/src/invocation.rs | 27 +++ crates/tools/src/read.rs | 51 ++++-- crates/tools/src/router.rs | 27 ++- crates/tui/src/chatwidget.rs | 47 ++++-- crates/tui/src/chatwidget_tests.rs | 69 +++++++- crates/tui/src/worker.rs | 100 ++++++++++- docs/spec-conversation.md | 5 + docs/spec-tools.md | 7 + 18 files changed, 687 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index c29e1d8..c7bb825 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ 🚧Early-stage project under active development — not production-ready yet. ⭐ Star us to follow -[![Status](https://img.shields.io/badge/status-designing-blue?style=flat-square)](https://github.com/) +[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) [![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![Origin](https://img.shields.io/badge/origin-Claude_Code_TS-8A2BE2?style=flat-square)](https://docs.anthropic.com/en/docs/claude-code) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) +[![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) +[![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) + [English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Español](./README.es.md) | [Français](./README.fr.md) | [Português do Brasil](./README.pt-BR.md) | [Deutsch](./README.de.md) | [Русский](./README.ru.md) | [Türkçe](./README.tr.md) @@ -52,7 +54,7 @@ irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex > [!TIP] > `devo` can check for newer GitHub releases on startup and print the matching > upgrade command. You can disable or tune this with the `[updates]` section in -> `DEVO_HOME/config.toml` or `/.devo/config.toml`. +> `DEVO_HOME/config.toml` (defaults to `~/.devo/config.toml` on macOS/linux, `C:\Users\yourname\.devo\config.toml` on Windows.) or `/.devo/config.toml`. ## 🚀 Quick Start @@ -80,7 +82,7 @@ Devo reads configuration from a TOML file, merged with higher-priority sources overriding lower-priority ones: 1. Built-in defaults (compiled into the binary) -2. `DEVO_HOME/config.toml` — user-level config (defaults to `~/.devo/config.toml`) +2. `DEVO_HOME/config.toml` — user-level config (defaults to `~/.devo/config.toml` on macOS/linux, `C:\Users\yourname\.devo\config.toml` on Windows.) 3. `/.devo/config.toml` — project-level config 4. CLI flags — command-line overrides @@ -109,7 +111,8 @@ model = "deepseek-v4-pro" model = "deepseek-v4-flash" ``` -### Full Config Reference +
+Full Config Reference (click to expand) ```toml # ── Model Provider (required) ─────────────────────────────────── @@ -155,7 +158,7 @@ persist_ephemeral_sessions = false # optional [logging] level = "info" # optional, trace, debug, info, warn, error -json = false # optional, emit JSON-formatted logs +json = false # optional, emit JSON-formatted logs redact_secrets_in_logs = true # optional [logging.file] @@ -165,18 +168,18 @@ rotation = "Daily" # optional, Never | Minutely | Hourly | Daily max_files = 14 # optional [skills] -enabled = true # optional -user_roots = ["skills"] # optional, dirs to scan for user skills -workspace_roots = ["skills"] # optional, dirs to scan for workspace skills -watch_for_changes = true # optional +enabled = true # optional +user_roots = ["skills"] # optional, dirs to scan for user skills +workspace_roots = ["skills"] # optional, dirs to scan for workspace skills +watch_for_changes = true # optional [updates] -enabled = true # optional -check_on_startup = true # optional -check_interval_hours = 24 # optional -``` - +enabled = true # optional +check_on_startup = true # optional +check_interval_hours = 24 # optional ### Model Catalog (`~/.devo/models.json`) +``` +
A separate JSON file defines available models and their capabilities. On first run, the built-in catalog is automatically copied to `~/.devo/models.json` so @@ -214,6 +217,17 @@ with credentials in `config.toml`, not the full catalog. |---------------|-----------------------------------------------| | `DEVO_HOME` | Override the config directory (default: `~/.devo`) | + +## Star us + + + + + + Star History Chart + + + ## FAQ ### How is this different from Claude Code? diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index 7f17fe3..46f01f0 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -153,6 +153,9 @@ pub struct ToolResultItem { pub tool_name: Option, /// The normalized structured output returned by the tool. pub output: serde_json::Value, + /// Optional UI-only text for displaying the result without changing replay. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_content: Option, /// Whether the result represents an error outcome. pub is_error: bool, } diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index 6b740ed..fbe904e 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -95,6 +95,7 @@ pub enum QueryEvent { ToolResult { tool_use_id: String, content: String, + display_content: Option, is_error: bool, /// Human-readable summary for client-side rendering (e.g. "bash: npm run dev"). summary: String, @@ -807,8 +808,21 @@ pub async fn query( return Ok(()); } - // Execute tool calls - let results = runtime.execute_batch(&tool_calls).await; + // Execute tool calls. When a caller is observing query events, wire + // tool progress into the same event stream so long-running commands can + // render live output before the final ToolResult arrives. + let results = if let Some(progress_events) = on_event.clone() { + runtime + .execute_batch_streaming(&tool_calls, move |tool_use_id, content| { + progress_events(QueryEvent::ToolProgress { + tool_use_id: tool_use_id.to_string(), + content: content.to_string(), + }); + }) + .await + } else { + runtime.execute_batch(&tool_calls).await + }; // Build tool call name -> input map for computing summaries let tool_call_map: std::collections::HashMap<&str, (&str, &serde_json::Value)> = tool_calls @@ -823,6 +837,7 @@ pub async fn query( .map(|r| { let content_str = r.content.into_string(); let compacted_content = micro_compact(content_str); + let compacted_display_content = r.display_content.map(micro_compact); let summary = tool_call_map .get(r.tool_use_id.as_str()) .map(|(name, input)| { @@ -832,6 +847,7 @@ pub async fn query( emit(QueryEvent::ToolResult { tool_use_id: r.tool_use_id.clone(), content: compacted_content.clone(), + display_content: compacted_display_content, is_error: r.is_error, summary: summary.clone(), }); @@ -1183,7 +1199,6 @@ mod tests { } } - #[async_trait] #[async_trait] impl ToolHandler for MutatingTool { fn tool_kind(&self) -> ToolHandlerKind { @@ -1199,6 +1214,45 @@ mod tests { } } + struct DisplayContentTool; + + #[async_trait] + impl ToolHandler for DisplayContentTool { + fn tool_kind(&self) -> ToolHandlerKind { + ToolHandlerKind::Read + } + + async fn handle( + &self, + _invocation: ToolInvocation, + _progress: Option, + ) -> Result, ToolExecutionError> { + Ok(Box::new( + FunctionToolOutput::success("canonical").with_display_content("display"), + )) + } + } + + struct StreamingMutatingTool; + + #[async_trait] + impl ToolHandler for StreamingMutatingTool { + fn tool_kind(&self) -> ToolHandlerKind { + ToolHandlerKind::Write + } + + async fn handle( + &self, + _invocation: ToolInvocation, + progress: Option, + ) -> Result, ToolExecutionError> { + if let Some(sender) = progress { + let _ = sender.send("stream chunk\n".to_string()); + } + Ok(Box::new(FunctionToolOutput::success("stream complete"))) + } + } + #[tokio::test] async fn query_retries_transient_stream_creation_errors() { let provider = Arc::new(TransientStreamCreateProvider { @@ -1810,4 +1864,135 @@ mod tests { assert!(!summary.is_empty(), "summary should not be empty"); } } + + #[tokio::test] + async fn query_emits_tool_result_display_content() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback = Arc::new(move |event: QueryEvent| { + if let QueryEvent::ToolResult { + content, + display_content, + .. + } = event + { + seen_clone.lock().unwrap().push((content, display_content)); + } + }); + + query( + &mut session, + &TurnConfig { + model: Model::default(), + thinking_selection: None, + }, + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[(String::from("canonical"), Some(String::from("display")))] + ); + } + + #[tokio::test] + async fn query_emits_tool_progress_before_tool_result() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(StreamingMutatingTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback = Arc::new(move |event: QueryEvent| { + seen_clone.lock().unwrap().push(event); + }); + + query( + &mut session, + &TurnConfig { + model: Model::default(), + thinking_selection: None, + }, + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + ) + .await + .expect("query should complete"); + + let events = seen.lock().unwrap(); + let progress_index = events + .iter() + .position(|event| { + matches!( + event, + QueryEvent::ToolProgress { + tool_use_id, + content, + } if tool_use_id == "tool-1" && content == "stream chunk\n" + ) + }) + .expect("tool progress event should be emitted"); + let result_index = events + .iter() + .position(|event| { + matches!( + event, + QueryEvent::ToolResult { + tool_use_id, + content, + is_error, + .. + } if tool_use_id == "tool-1" + && content == "stream complete" + && !is_error + ) + }) + .expect("tool result event should be emitted"); + + assert!( + progress_index < result_index, + "tool progress should arrive before final result" + ); + } } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 25931a7..11bf804 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -32,6 +32,8 @@ pub struct ToolResultPayload { pub tool_call_id: String, pub tool_name: Option, pub content: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_content: Option, pub is_error: bool, #[serde(default)] pub summary: String, @@ -322,6 +324,35 @@ mod tests { assert_eq!(restored.pending_texts, vec!["first", "second"]); } + #[test] + fn tool_result_payload_display_content_is_optional() { + let payload: ToolResultPayload = serde_json::from_str( + r#"{ + "tool_call_id": "call-1", + "tool_name": "read", + "content": "canonical", + "is_error": false + }"#, + ) + .expect("deserialize legacy payload"); + assert_eq!(payload.display_content, None); + assert_eq!(payload.summary, ""); + + let payload = ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + content: serde_json::Value::String("canonical".to_string()), + display_content: Some("display".to_string()), + is_error: false, + summary: "read output".to_string(), + }; + let json = serde_json::to_value(&payload).expect("serialize payload"); + assert_eq!( + json.get("display_content"), + Some(&serde_json::Value::String("display".to_string())) + ); + } + #[test] fn steer_accepted_event_roundtrips() { let turn_id = TurnId::new(); diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 4303c4a..9442197 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -723,11 +723,13 @@ pub(crate) fn apply_turn_item( tool_call_id, tool_name, output, + display_content, is_error, }) => TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_call_id.clone(), tool_name: tool_name.or_else(|| tool_names_by_id.get(&tool_call_id).cloned()), output, + display_content, is_error, }), TurnItem::CommandExecution(CommandExecutionItem { @@ -793,6 +795,7 @@ pub(crate) fn apply_turn_item( tool_call_id, tool_name: _, output, + display_content: _, is_error, }) => { let content = match output { @@ -912,11 +915,13 @@ fn apply_prompt_turn_item( tool_call_id, tool_name, output, + display_content, is_error, }) => TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_call_id.clone(), tool_name: tool_name.or_else(|| tool_names_by_id.get(&tool_call_id).cloned()), output, + display_content, is_error, }), TurnItem::CommandExecution(CommandExecutionItem { @@ -979,6 +984,7 @@ fn apply_prompt_turn_item( tool_call_id, tool_name: _, output, + display_content: _, is_error, }) => { let content = match output { @@ -1278,6 +1284,7 @@ mod tests { tool_call_id: "call-1".to_string(), tool_name: None, output: serde_json::Value::String("hello".to_string()), + display_content: None, is_error: false, }), ); @@ -1287,6 +1294,49 @@ mod tests { assert_eq!(history_items[1].title, "read output"); } + #[test] + fn replay_uses_display_content_for_history_but_canonical_output_for_prompt() { + let mut messages = Vec::new(); + let mut history_items = Vec::new(); + let mut tool_names_by_id = HashMap::new(); + + apply_turn_item( + &mut messages, + &mut history_items, + &mut tool_names_by_id, + TurnItem::ToolCall(ToolCallItem { + tool_call_id: "call-1".to_string(), + tool_name: "read".to_string(), + input: serde_json::json!({"filePath":"/tmp/test.txt"}), + }), + ); + apply_turn_item( + &mut messages, + &mut history_items, + &mut tool_names_by_id, + TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + output: serde_json::Value::String("canonical".to_string()), + display_content: Some("canonical".to_string()), + is_error: false, + }), + ); + + assert_eq!(history_items[1].body, "canonical"); + assert_eq!( + messages.last(), + Some(&Message { + role: devo_core::Role::User, + content: vec![devo_core::ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "canonical".to_string(), + is_error: false, + }], + }) + ); + } + #[test] fn prompt_messages_rebuild_from_compaction_snapshot_without_trimming_transcript() { let summary_item_id = ItemId::new(); diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index b18cd0b..454ec55 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -137,6 +137,7 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option Some(SessionHistoryItem::new( @@ -147,10 +148,10 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option text.clone(), other => other.to_string(), - }, + }), )), TurnItem::CommandExecution(CommandExecutionItem { tool_call_id, @@ -270,3 +271,43 @@ fn summarize_tool_result(tool_name: Option<&str>, is_error: bool) -> String { (None, false) => "Tool output".to_string(), } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::history_item_from_turn_item; + use crate::session::SessionHistoryItemKind; + use devo_core::ToolResultItem; + use devo_core::TurnItem; + + #[test] + fn history_projection_prefers_tool_result_display_content() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + output: serde_json::Value::String("canonical".to_string()), + display_content: Some("canonical".to_string()), + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + assert_eq!(history_item.kind, SessionHistoryItemKind::ToolResult); + assert_eq!(history_item.title, "read output"); + assert_eq!(history_item.body, "canonical"); + } + + #[test] + fn history_projection_falls_back_to_tool_result_output() { + let item = TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + output: serde_json::Value::String("canonical".to_string()), + display_content: None, + is_error: false, + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + assert_eq!(history_item.body, "canonical"); + } +} diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index ccd2e2e..7582511 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -329,6 +329,7 @@ impl ServerRuntime { QueryEvent::ToolResult { tool_use_id, content, + display_content, is_error, summary, } => { @@ -399,12 +400,14 @@ impl ServerRuntime { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), output: serde_json::Value::String(content.clone()), + display_content: display_content.clone(), is_error, }), serde_json::to_value(ToolResultPayload { tool_call_id: tool_use_id.clone(), tool_name, content: serde_json::Value::String(content), + display_content, is_error, summary, }) diff --git a/crates/tools/src/handlers/exec_command.rs b/crates/tools/src/handlers/exec_command.rs index 7d378c4..cb05e53 100644 --- a/crates/tools/src/handlers/exec_command.rs +++ b/crates/tools/src/handlers/exec_command.rs @@ -510,6 +510,49 @@ mod tests { assert_eq!(chunks.join(""), text); } + #[cfg(unix)] + #[tokio::test] + async fn exec_command_streams_progress_before_final_output() { + let root = std::env::temp_dir().join(format!("devo-exec-stream-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp test dir"); + let handler = ExecCommandHandler::new(Arc::new(ProcessStore::new())); + let invocation = ToolInvocation { + call_id: crate::invocation::ToolCallId("call-1".to_string()), + tool_name: crate::invocation::ToolName("exec_command".into()), + session_id: "session-1".to_string(), + cwd: root.clone(), + input: serde_json::json!({ + "cmd": "printf 'first\\n'; sleep 0.05; printf 'second\\n'", + "login": false, + "yield_time_ms": 1000, + "max_output_tokens": 1000, + }), + }; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { handler.handle(invocation, Some(tx)).await }); + + let first_chunk = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timed out waiting for streamed output") + .expect("progress channel should produce a chunk"); + assert!( + first_chunk.contains("first"), + "first streamed chunk should contain initial output: {first_chunk:?}" + ); + + let output = tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("timed out waiting for exec command") + .expect("exec command task should not panic") + .expect("handle exec command") + .to_content() + .into_string(); + + assert!(output.contains("first")); + assert!(output.contains("second")); + std::fs::remove_dir_all(root).expect("cleanup temp test dir"); + } + #[test] fn exec_command_args_missing_cmd() { let args = serde_json::json!({}); diff --git a/crates/tools/src/handlers/plan.rs b/crates/tools/src/handlers/plan.rs index 6920c8c..f0af5fd 100644 --- a/crates/tools/src/handlers/plan.rs +++ b/crates/tools/src/handlers/plan.rs @@ -63,6 +63,7 @@ impl ToolHandler for PlanHandler { })), }, is_error: false, + display_content: None, })) } } diff --git a/crates/tools/src/handlers/webfetch.rs b/crates/tools/src/handlers/webfetch.rs index b138063..0029fff 100644 --- a/crates/tools/src/handlers/webfetch.rs +++ b/crates/tools/src/handlers/webfetch.rs @@ -124,6 +124,7 @@ impl ToolHandler for WebFetchHandler { })), }, is_error: false, + display_content: None, })); } @@ -153,6 +154,7 @@ impl ToolHandler for WebFetchHandler { json: Some(serde_json::json!({ "title": title, "mime": mime })), }, is_error: false, + display_content: None, })) } } diff --git a/crates/tools/src/invocation.rs b/crates/tools/src/invocation.rs index bfa666d..76aac47 100644 --- a/crates/tools/src/invocation.rs +++ b/crates/tools/src/invocation.rs @@ -23,6 +23,9 @@ pub struct ToolInvocation { pub trait ToolOutput: Send { fn to_content(self: Box) -> ToolContent; fn is_error(&self) -> bool; + fn display_content(&self) -> Option<&str> { + None + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,6 +68,7 @@ impl ToolContent { pub struct FunctionToolOutput { pub content: ToolContent, pub is_error: bool, + pub display_content: Option, } impl FunctionToolOutput { @@ -72,6 +76,7 @@ impl FunctionToolOutput { FunctionToolOutput { content: ToolContent::Text(content.into()), is_error: false, + display_content: None, } } @@ -79,6 +84,7 @@ impl FunctionToolOutput { FunctionToolOutput { content: ToolContent::Text(message.into()), is_error: true, + display_content: None, } } @@ -89,8 +95,14 @@ impl FunctionToolOutput { json: Some(metadata), }, is_error: false, + display_content: None, } } + + pub fn with_display_content(mut self, display_content: impl Into) -> Self { + self.display_content = Some(display_content.into()); + self + } } impl ToolOutput for FunctionToolOutput { @@ -101,6 +113,10 @@ impl ToolOutput for FunctionToolOutput { fn is_error(&self) -> bool { self.is_error } + + fn display_content(&self) -> Option<&str> { + self.display_content.as_deref() + } } #[cfg(test)] @@ -173,6 +189,7 @@ mod tests { fn function_tool_output_success() { let out = FunctionToolOutput::success("done"); assert!(!out.is_error); + assert_eq!(out.display_content(), None); assert!(matches!(out.content, ToolContent::Text(ref t) if t == "done")); } @@ -180,6 +197,7 @@ mod tests { fn function_tool_output_error() { let out = FunctionToolOutput::error("failed"); assert!(out.is_error); + assert_eq!(out.display_content(), None); assert!(matches!(out.content, ToolContent::Text(ref t) if t == "failed")); } @@ -188,6 +206,7 @@ mod tests { let out = FunctionToolOutput::success_with_metadata("result", serde_json::json!({"key": "val"})); assert!(!out.is_error); + assert_eq!(out.display_content(), None); match out.content { ToolContent::Mixed { text, json } => { assert_eq!(text, Some("result".into())); @@ -197,10 +216,18 @@ mod tests { } } + #[test] + fn function_tool_output_with_display_content() { + let out = FunctionToolOutput::success("canonical").with_display_content("display"); + assert_eq!(out.display_content(), Some("display")); + assert!(matches!(out.content, ToolContent::Text(ref text) if text == "canonical")); + } + #[test] fn tool_output_trait_impl() { let out = Box::new(FunctionToolOutput::success("trait test")); assert!(!out.is_error()); + assert_eq!(out.display_content(), None); let content = out.to_content(); assert!(matches!(content, ToolContent::Text(ref t) if t == "trait test")); } diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index 596c2d3..9318683 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -37,10 +37,7 @@ pub(crate) fn read_directory( .collect::>() .join("\n"); - let output = [ - format!("{}", path.display()), - "directory".to_string(), - "".to_string(), + let display_content = [ sliced.join("\n"), if truncated { format!( @@ -52,6 +49,14 @@ pub(crate) fn read_directory( } else { format!("\n({} entries)", items.len()) }, + ] + .join("\n"); + + let output = [ + format!("{}", path.display()), + "directory".to_string(), + "".to_string(), + display_content.clone(), "".to_string(), ] .join("\n"); @@ -63,7 +68,8 @@ pub(crate) fn read_directory( "truncated": truncated, "loaded": [] }), - )) + ) + .with_display_content(display_content)) } pub(crate) fn read_file( @@ -112,30 +118,30 @@ pub(crate) fn read_file( ))); } - let mut output = format!( - "{}\nfile\n\n", - path.display() - ); + let mut display_content = String::new(); for (index, line) in raw.iter().enumerate() { - output.push_str(&format!("{}: {}\n", offset + index, line)); + display_content.push_str(&format!("{}: {}\n", offset + index, line)); } let last = offset + raw.len().saturating_sub(1); let next = last + 1; if cut { - output.push_str(&format!( + display_content.push_str(&format!( "\n(Output capped at 50 KB. Showing lines {}-{}. Use offset={} to continue.)", offset, last, next )); } else if more { - output.push_str(&format!( + display_content.push_str(&format!( "\n(Showing lines {}-{} of {}. Use offset={} to continue.)", offset, last, count, next )) } else { - output.push_str(&format!("\n(End of file - total {} lines)", count)) + display_content.push_str(&format!("\n(End of file - total {} lines)", count)) } - output.push_str("\n"); + let output = format!( + "{}\nfile\n\n{display_content}\n", + path.display() + ); Ok(FunctionToolOutput::success_with_metadata( output, @@ -144,7 +150,8 @@ pub(crate) fn read_file( "truncated": cut || more, "loaded": [] }), - )) + ) + .with_display_content(display_content)) } pub(crate) fn is_binary_file(path: &Path) -> anyhow::Result { @@ -253,6 +260,7 @@ pub(crate) fn missing_file_message(filepath: &str) -> String { mod tests { use super::*; use crate::ToolContent; + use crate::invocation::ToolOutput; use pretty_assertions::assert_eq; use std::env; use std::fs::File; @@ -312,6 +320,13 @@ mod tests { "(Showing 1 of 3 entries. Use 'offset' parameter to read beyond entry 3)" ) ); + assert_eq!( + output.display_content(), + Some( + "b.txt\n\n(Showing 1 of 3 entries. Use 'offset' parameter to read beyond entry 3)" + ) + ); + assert!(!output.display_content().unwrap().contains("")); assert_eq!( output_metadata(&output) @@ -333,6 +348,12 @@ mod tests { assert!(text.contains("2: line2")); assert!(text.contains("3: line3")); assert!(text.contains("(Showing lines 2-3 of 5. Use offset=4 to continue.)")); + assert_eq!( + output.display_content(), + Some("2: line2\n3: line3\n\n(Showing lines 2-3 of 5. Use offset=4 to continue.)") + ); + assert!(!output.display_content().unwrap().contains("")); + assert!(!output.display_content().unwrap().contains("")); assert_eq!( output_metadata(&output) diff --git a/crates/tools/src/router.rs b/crates/tools/src/router.rs index df5dac3..a52b30f 100644 --- a/crates/tools/src/router.rs +++ b/crates/tools/src/router.rs @@ -15,6 +15,7 @@ type ProgressCallback = dyn Fn(&str, &str) + Send + Sync; type ProgressCallbackArc = Arc; type PermissionFuture = futures::future::BoxFuture<'static, Result<(), String>>; type PermissionCheckFn = dyn Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync; +const PROGRESS_DRAIN_GRACE_MS: u64 = 50; #[derive(Debug, Clone)] pub struct ToolCall { @@ -28,6 +29,7 @@ pub struct ToolCallResult { pub tool_use_id: String, pub content: ToolContent, pub is_error: bool, + pub display_content: Option, } impl ToolCallResult { @@ -36,6 +38,7 @@ impl ToolCallResult { tool_use_id: tool_use_id.to_string(), content, is_error: false, + display_content: None, } } @@ -44,6 +47,7 @@ impl ToolCallResult { tool_use_id: tool_use_id.to_string(), content: ToolContent::Text(message.to_string()), is_error: true, + display_content: None, } } } @@ -168,26 +172,39 @@ impl ToolRuntime { input: call.input.clone(), }; - let progress_sender = on_progress.as_ref().map(|cb| { + let (progress_sender, mut progress_task) = if let Some(cb) = on_progress.as_ref() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let call_id = call.id.clone(); let cb = Arc::clone(cb); - tokio::spawn(async move { + let task = tokio::spawn(async move { while let Some(chunk) = rx.recv().await { cb(&call_id, &chunk); } }); - tx - }); + (Some(tx), Some(task)) + } else { + (None, None) + }; + + let result = tool.handle(invocation, progress_sender).await; + if let Some(task) = progress_task.as_mut() { + let _ = tokio::time::timeout( + std::time::Duration::from_millis(PROGRESS_DRAIN_GRACE_MS), + task, + ) + .await; + } - match tool.handle(invocation, progress_sender).await { + match result { Ok(output) => { let is_error = output.is_error(); + let display_content = output.display_content().map(str::to_string); let content = output.to_content(); ToolCallResult { tool_use_id: call.id.clone(), content, is_error, + display_content, } } Err(e) => ToolCallResult::error(&call.id, &e.to_string()), diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 04d0091..7d44cb7 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -646,8 +646,19 @@ impl ChatWidget { is_first_run: bool, startup_tooltip_override: Option, ) { + self.history.push(self.build_current_header_box( + is_first_run, + startup_tooltip_override, + )); + } + + fn build_current_header_box( + &self, + is_first_run: bool, + startup_tooltip_override: Option, + ) -> Box { let accent = self.active_accent_color(); - self.history.push(Self::build_header_box( + Self::build_header_box( &self.session.cwd, self.session.model.as_ref(), self.thinking_selection.as_deref(), @@ -655,7 +666,15 @@ impl ChatWidget { startup_tooltip_override, accent, self.startup_header_mascot_frame_index, - )); + ) + } + + fn history_has_non_header_content(&self) -> bool { + self.history.iter().any(|cell| { + cell.as_any() + .downcast_ref::() + .is_none() + }) } fn rebuild_restored_session_history( @@ -1546,27 +1565,35 @@ impl ChatWidget { model, thinking, reasoning_effort, - last_query_total_tokens, - last_query_input_tokens, - total_cache_read_tokens, + last_query_total_tokens: _, + last_query_input_tokens: _, + total_cache_read_tokens: _, } => { self.resume_browser_loading = false; self.session.cwd = cwd; self.update_session_request_model(model); self.thinking_selection = thinking; self.session.reasoning_effort = reasoning_effort; + let should_append_header = self.history_has_non_header_content(); + self.active_cell = None; + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.active_tool_calls.clear(); + self.pending_tool_calls.clear(); self.active_text_items.clear(); self.stream_chunking_policy.reset(); - self.history.clear(); - self.next_history_flush_index = 0; self.busy = false; self.turn_count = 0; self.total_input_tokens = 0; self.total_output_tokens = 0; - self.total_cache_read_tokens = total_cache_read_tokens; - self.last_query_total_tokens = last_query_total_tokens; - self.last_query_input_tokens = last_query_input_tokens; + self.total_cache_read_tokens = 0; + self.last_query_total_tokens = 0; + self.last_query_input_tokens = 0; self.prompt_token_estimate = 0; + if should_append_header { + self.push_session_header(/*is_first_run*/ false, None); + } else { + self.refresh_header_box(); + } self.set_status_message("New session ready; send a prompt to start it"); } WorkerEvent::SessionSwitched { diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index f6821ca..1376548 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -1860,7 +1860,7 @@ fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { } #[test] -fn new_session_prepared_resets_session_identity_projection() { +fn new_session_prepared_appends_header_after_existing_history_and_resets_status() { let initial_cwd = std::env::current_dir().expect("current directory is available"); let resumed_cwd = initial_cwd.join("resumed"); let model = Model { @@ -1877,24 +1877,29 @@ fn new_session_prepared_resets_session_identity_projection() { model: Some("resumed-model".to_string()), thinking: None, reasoning_effort: None, - total_input_tokens: 3, + total_input_tokens: 30, total_output_tokens: 5, - total_cache_read_tokens: 0, - last_query_total_tokens: 8, - last_query_input_tokens: 3, - prompt_token_estimate: 3, + total_cache_read_tokens: 12, + last_query_total_tokens: 25, + last_query_input_tokens: 20, + prompt_token_estimate: 20, history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], }); + widget.add_to_history(crate::history_cell::new_info_event( + "old session line".to_string(), + None, + )); + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { cwd: initial_cwd.clone(), model: "new-session-model".to_string(), thinking: None, reasoning_effort: None, - last_query_total_tokens: 0, - last_query_input_tokens: 0, - total_cache_read_tokens: 0, + last_query_total_tokens: 25, + last_query_input_tokens: 20, + total_cache_read_tokens: 12, }); assert_eq!(widget.current_cwd(), initial_cwd.as_path()); @@ -1902,6 +1907,52 @@ fn new_session_prepared_resets_session_identity_projection() { widget.current_model().map(|model| model.slug.as_str()), Some("new-session-model") ); + + let summary = widget.status_summary_text(); + assert!(summary.contains("↑0")); + assert!(summary.contains("↺0 0%")); + assert!(summary.contains("↓0")); + assert!(summary.contains("0/190k")); + + let transcript_lines = scrollback_plain_lines( + &widget + .transcript_overlay_lines(80) + .into_iter() + .map(crate::history_cell::ScrollbackLine::new) + .collect::>(), + ); + let transcript_text = transcript_lines.join("\n"); + assert!(transcript_text.contains("old session line")); + let old_line_index = find_row_index(&transcript_lines, "old session line") + .expect("old session line remains in transcript"); + let header_index = find_row_index(&transcript_lines, "Devo") + .expect("new session header is appended"); + assert!(header_index > old_line_index); +} + +#[test] +fn new_session_prepared_does_not_duplicate_startup_header_without_history() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd, + model: "new-session-model".to_string(), + thinking: None, + reasoning_effort: None, + last_query_total_tokens: 10, + last_query_input_tokens: 10, + total_cache_read_tokens: 4, + }); + + let rows = rendered_rows(&widget, 80, 16); + assert_eq!(rows.iter().filter(|row| row.contains("Devo")).count(), 1); + assert!(widget.status_summary_text().contains("↺0 0%")); } #[test] diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index db5cab1..dd05805 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -693,7 +693,12 @@ async fn run_worker_inner( session_id = None; session_cwd = config.cwd.clone(); input_history_cursor = None; + turn_count = 0; + total_input_tokens = 0; + total_output_tokens = 0; + total_cache_read_tokens = 0; last_query_total_tokens = 0; + last_query_input_tokens = 0; let _ = event_tx.send(WorkerEvent::NewSessionPrepared { cwd: session_cwd.clone(), model: model.clone(), @@ -1615,7 +1620,9 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe let _ = event_tx.send(WorkerEvent::ToolResult { tool_use_id: payload.tool_call_id, title, - preview: render_json_value_text(&payload.content), + preview: payload + .display_content + .unwrap_or_else(|| render_json_value_text(&payload.content)), is_error: payload.is_error, truncated: false, }); @@ -2083,6 +2090,7 @@ mod tests { use devo_server::SessionMetadata; use devo_server::SessionRuntimeStatus; + use super::handle_completed_item; use super::normalize_display_output; use super::project_history_items; use super::summarize_tool_call; @@ -2090,9 +2098,15 @@ mod tests { use crate::events::SessionListEntry; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; + use crate::events::WorkerEvent; + use devo_core::ItemId; + use devo_server::ItemEnvelope; + use devo_server::ItemEventPayload; + use devo_server::ItemKind; use devo_server::SessionHistoryItem; use devo_server::SessionHistoryItemKind; use devo_server::ToolCallPayload; + use devo_server::ToolResultPayload; #[test] fn bash_tool_summary_uses_command_text() { @@ -2123,6 +2137,90 @@ mod tests { ); } + #[test] + fn completed_tool_result_uses_display_content_preview() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + content: serde_json::Value::String( + "canonical".to_string(), + ), + display_content: Some("canonical".to_string()), + is_error: false, + summary: "read output".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + assert_eq!( + event_rx.try_recv().expect("worker event"), + WorkerEvent::ToolResult { + tool_use_id: "call-1".to_string(), + title: "read output".to_string(), + preview: "canonical".to_string(), + is_error: false, + truncated: false, + } + ); + } + + #[test] + fn completed_tool_result_falls_back_to_content_preview() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + handle_completed_item( + ItemEventPayload { + context: devo_server::EventContext { + session_id: SessionId::new(), + turn_id: None, + item_id: None, + seq: 1, + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolResult, + payload: serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: Some("read".to_string()), + content: serde_json::Value::String( + "canonical".to_string(), + ), + display_content: None, + is_error: false, + summary: "read output".to_string(), + }) + .expect("serialize tool result payload"), + }, + }, + &event_tx, + ); + + assert_eq!( + event_rx.try_recv().expect("worker event"), + WorkerEvent::ToolResult { + tool_use_id: "call-1".to_string(), + title: "read output".to_string(), + preview: "canonical".to_string(), + is_error: false, + truncated: false, + } + ); + } + #[test] fn session_list_entries_keep_title_before_identifier() { let active_session_id = SessionId::new(); diff --git a/docs/spec-conversation.md b/docs/spec-conversation.md index 5d89d4b..f6f9649 100644 --- a/docs/spec-conversation.md +++ b/docs/spec-conversation.md @@ -207,6 +207,11 @@ pub struct ItemRecord { } ``` +Generic `ToolResultItem` records store canonical tool output for replay and may +also carry optional `display_content` for transcript projection. When present, +clients should use `display_content` only for UI rendering; prompt rebuilding, +provider history, and persisted canonical semantics continue to use `output`. + ### Rollout Line Primary persisted history must be written as a single append-only rollout stream. diff --git a/docs/spec-tools.md b/docs/spec-tools.md index 356343c..a8c2ce3 100644 --- a/docs/spec-tools.md +++ b/docs/spec-tools.md @@ -142,6 +142,9 @@ pub enum ToolExecutionOutcome { ```rust pub struct ToolResultPayload { pub content: ToolContent, + /// Optional UI-only text. Model replay and persisted canonical output must + /// continue using `content`; clients may use this for cleaner display. + pub display_content: Option, pub metadata: ToolResultMetadata, } @@ -158,6 +161,10 @@ pub enum ToolContent { Current implementation note: tool handlers return `Box` from `invocation.rs`; `FunctionToolOutput` is the standard concrete implementation, and legacy text-plus-metadata results should be represented as `ToolContent::Mixed`. +`display_content` is best-effort display text, not a replacement for canonical +tool content. The `read` tool uses it to show file bodies without `` and +directory listings without ``, while keeping the full tagged output for +the model, history replay, and provider context. ## Tool Trait Contract From 292b68d6205bd506ac4ed0f9c601b435572482be Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 14 May 2026 16:21:00 +0800 Subject: [PATCH 4/4] fix: cargo fmt --- crates/tui/src/chatwidget.rs | 6 ++---- crates/tui/src/chatwidget_tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 7d44cb7..2825246 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -646,10 +646,8 @@ impl ChatWidget { is_first_run: bool, startup_tooltip_override: Option, ) { - self.history.push(self.build_current_header_box( - is_first_run, - startup_tooltip_override, - )); + self.history + .push(self.build_current_header_box(is_first_run, startup_tooltip_override)); } fn build_current_header_box( diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 1376548..9b0ed47 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -1925,8 +1925,8 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( assert!(transcript_text.contains("old session line")); let old_line_index = find_row_index(&transcript_lines, "old session line") .expect("old session line remains in transcript"); - let header_index = find_row_index(&transcript_lines, "Devo") - .expect("new session header is appended"); + let header_index = + find_row_index(&transcript_lines, "Devo").expect("new session header is appended"); assert!(header_index > old_line_index); }