fix(tui): merged tool request+result blocks; fix wheel-down bottom jump
1. wheel-down jumped to the bottom because max_scroll() derived the limit from
geometry, which only covers expandable blocks — a text-only conversation
left it at 0, so one down-notch clamped scroll to 0 with follow=true and
the next draw snapped to the real bottom. The limit now uses
content_lines (the full rendered line count, stored per draw).
2. tool request and result are now one block ('requested where it returns'):
UiBlock::ToolCall/ToolResult/ToolExec are unified into UiBlock::Tool, built
by merging each assistant tool call with its matching tool_result message
(looked up by call_id). Collapsed shows the concrete summary + duration +
status; expanded shows human-readable args, then '── 结果 ──', then the
output (two-level: partial preview with remaining-line hint, then full).
Live running executions reuse the same block. ToolResult messages are no
longer rendered standalone.
tests updated for the merged model; wheel test now drives content_lines.
This commit is contained in:
parent
acddf7be1b
commit
7d289580b5
|
|
@ -214,6 +214,10 @@ pub struct App {
|
||||||
/// 消息区视图高度(由 draw 填充)。
|
/// 消息区视图高度(由 draw 填充)。
|
||||||
/// Messages-area view height (filled by draw).
|
/// Messages-area view height (filled by draw).
|
||||||
pub view_height: u16,
|
pub view_height: u16,
|
||||||
|
/// 消息区总内容行数(由 draw 填充;滚动上限依据)。
|
||||||
|
/// Total content lines of the message area (filled by draw; the scroll
|
||||||
|
/// limit is based on it).
|
||||||
|
pub content_lines: u16,
|
||||||
/// 帧计数(供 spinner 动画)。
|
/// 帧计数(供 spinner 动画)。
|
||||||
/// Frame counter (drives the spinner animation).
|
/// Frame counter (drives the spinner animation).
|
||||||
pub frame: u64,
|
pub frame: u64,
|
||||||
|
|
@ -249,6 +253,7 @@ impl App {
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
geometry: Vec::new(),
|
geometry: Vec::new(),
|
||||||
view_height: 0,
|
view_height: 0,
|
||||||
|
content_lines: 0,
|
||||||
frame: 0,
|
frame: 0,
|
||||||
quit: false,
|
quit: false,
|
||||||
}
|
}
|
||||||
|
|
@ -892,26 +897,18 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 最大滚动偏移(由最近渲染的几何决定)。
|
/// 最大滚动偏移(由最近渲染的总行数决定;几何只含可展开块,不能用作依据)。
|
||||||
/// Max scroll offset (derived from the last render's geometry).
|
/// Max scroll offset (based on the last render's total line count; the
|
||||||
|
/// geometry only covers expandable blocks and is unusable for this).
|
||||||
fn max_scroll(&self) -> u16 {
|
fn max_scroll(&self) -> u16 {
|
||||||
let total = self.geometry.last().map(|g| g.end).unwrap_or(0);
|
self.content_lines.saturating_sub(self.view_height.max(1))
|
||||||
total.saturating_sub(self.view_height.max(1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换最近一个可折叠块。
|
/// 切换最近一个可折叠块(几何只含可展开块:思考与工具)。
|
||||||
/// Toggle the most recent collapsible block.
|
/// Toggle the most recent collapsible block (geometry only holds
|
||||||
|
/// expandable blocks: thinking and tools).
|
||||||
fn toggle_latest_collapsible(&mut self) {
|
fn toggle_latest_collapsible(&mut self) {
|
||||||
let id = self
|
if let Some(id) = self.geometry.last().map(|g| g.id.clone()) {
|
||||||
.geometry
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|g| {
|
|
||||||
let id = &g.id;
|
|
||||||
id.0.starts_with("live-") || id.0.starts_with("exec-") || id.0.contains("-r")
|
|
||||||
})
|
|
||||||
.map(|g| g.id.clone());
|
|
||||||
if let Some(id) = id {
|
|
||||||
cycle_expansion(&mut self.expanded, &mut self.full, &id);
|
cycle_expansion(&mut self.expanded, &mut self.full, &id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,41 +63,30 @@ pub enum UiBlock {
|
||||||
/// partial preview).
|
/// partial preview).
|
||||||
full: bool,
|
full: bool,
|
||||||
},
|
},
|
||||||
/// assistant 发出的工具调用。
|
|
||||||
/// A tool call issued by the assistant.
|
|
||||||
ToolCall {
|
|
||||||
id: BlockId,
|
|
||||||
name: String,
|
|
||||||
summary: String,
|
|
||||||
args: JsonValue,
|
|
||||||
expanded: bool,
|
|
||||||
},
|
|
||||||
/// 工具执行结果消息(来自已提交的 transcript)。
|
|
||||||
/// A tool-result message (from the committed transcript).
|
|
||||||
ToolResult {
|
|
||||||
id: BlockId,
|
|
||||||
name: String,
|
|
||||||
text: String,
|
|
||||||
is_error: bool,
|
|
||||||
duration_ms: Option<u64>,
|
|
||||||
expanded: bool,
|
|
||||||
full: bool,
|
|
||||||
},
|
|
||||||
/// 运行错误(assistant 消息的 error_message,红色显示)。
|
/// 运行错误(assistant 消息的 error_message,红色显示)。
|
||||||
/// A run error (an assistant message's error_message, shown in red).
|
/// A run error (an assistant message's error_message, shown in red).
|
||||||
Error { id: BlockId, text: String },
|
Error { id: BlockId, text: String },
|
||||||
/// 工具执行过程(运行中 / 完成,含耗时与输出)。
|
/// 一次工具执行:请求(参数)与结果合并为一块——在那请求,在那返回。
|
||||||
/// A tool execution (running / done, with duration and output).
|
/// One tool execution: the request (arguments) and its result are merged
|
||||||
ToolExec {
|
/// into a single block — requested where it returns.
|
||||||
|
Tool {
|
||||||
id: BlockId,
|
id: BlockId,
|
||||||
name: String,
|
name: String,
|
||||||
|
/// 折叠摘要(具体内容,如 `read /path` / `shell: cmd`)。
|
||||||
|
/// Collapsed summary (concrete content, e.g. `read /path`).
|
||||||
summary: String,
|
summary: String,
|
||||||
|
/// 参数(展开时以人类可读键值对展示)。
|
||||||
|
/// Arguments (shown human-readably when expanded).
|
||||||
|
args: JsonValue,
|
||||||
status: ExecStatus,
|
status: ExecStatus,
|
||||||
duration_ms: Option<u64>,
|
duration_ms: Option<u64>,
|
||||||
|
/// 结果文本(或运行中的流式输出)。
|
||||||
|
/// Result text (or live streamed output while running).
|
||||||
output: String,
|
output: String,
|
||||||
result_text: String,
|
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
expanded: bool,
|
expanded: bool,
|
||||||
|
/// 二级展开:true 显示全部(false 只显示部分预览)。
|
||||||
|
/// Second-level expansion: true shows everything.
|
||||||
full: bool,
|
full: bool,
|
||||||
},
|
},
|
||||||
/// 系统提示(欢迎、帮助、状态说明等)。
|
/// 系统提示(欢迎、帮助、状态说明等)。
|
||||||
|
|
@ -376,6 +365,16 @@ pub fn build_blocks(
|
||||||
full: &HashSet<BlockId>,
|
full: &HashSet<BlockId>,
|
||||||
) -> Vec<UiBlock> {
|
) -> Vec<UiBlock> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
// 预收集 tool_result:call_id → (结果消息, 消息索引),用于把结果合并到
|
||||||
|
// 对应的工具调用块里(在那请求,在那返回)。
|
||||||
|
// Pre-collect tool_results: call_id → (result message, index), so each
|
||||||
|
// result merges into its tool-call block (requested where it returns).
|
||||||
|
let mut results: HashMap<&str, (&ToolResultMessage, usize)> = HashMap::new();
|
||||||
|
for (mi, msg) in transcript.iter().enumerate() {
|
||||||
|
if let Message::ToolResult(t) = msg {
|
||||||
|
results.entry(t.tool_call_id.as_str()).or_insert((t, mi));
|
||||||
|
}
|
||||||
|
}
|
||||||
for (mi, msg) in transcript.iter().enumerate() {
|
for (mi, msg) in transcript.iter().enumerate() {
|
||||||
match msg {
|
match msg {
|
||||||
Message::User(u) => {
|
Message::User(u) => {
|
||||||
|
|
@ -393,14 +392,43 @@ pub fn build_blocks(
|
||||||
stop_reason: a.stop_reason,
|
stop_reason: a.stop_reason,
|
||||||
});
|
});
|
||||||
for (bi, block) in a.content.iter().enumerate() {
|
for (bi, block) in a.content.iter().enumerate() {
|
||||||
push_content_block(
|
match block {
|
||||||
|
ContentBlock::ToolCall(tc) => {
|
||||||
|
// 工具调用与其结果合并为一块。
|
||||||
|
// The tool call merges with its result.
|
||||||
|
let id = BlockId(format!("m{}-{}", mi, bi));
|
||||||
|
let result = results.get(tc.id.as_str()).copied();
|
||||||
|
let (status, duration, output, is_error) = match result {
|
||||||
|
Some((r, r_idx)) => (
|
||||||
|
ExecStatus::Done,
|
||||||
|
tool_durations.get(&(r_idx, 0)).copied(),
|
||||||
|
join_text(&r.content),
|
||||||
|
r.is_error,
|
||||||
|
),
|
||||||
|
None => (ExecStatus::Done, None, String::new(), false),
|
||||||
|
};
|
||||||
|
out.push(UiBlock::Tool {
|
||||||
|
id,
|
||||||
|
name: tc.name.clone(),
|
||||||
|
summary: crate::format::summarize_tool(&tc.name, &tc.arguments),
|
||||||
|
args: tc.arguments.clone(),
|
||||||
|
status,
|
||||||
|
duration_ms: duration,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
expanded: expanded.contains(&BlockId(format!("m{}-{}", mi, bi))),
|
||||||
|
full: full.contains(&BlockId(format!("m{}-{}", mi, bi))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => push_content_block(
|
||||||
&mut out,
|
&mut out,
|
||||||
BlockId(format!("m{}-{}", mi, bi)),
|
BlockId(format!("m{}-{}", mi, bi)),
|
||||||
block,
|
other,
|
||||||
thinking_durations.get(&(mi, bi)).copied(),
|
thinking_durations.get(&(mi, bi)).copied(),
|
||||||
expanded,
|
expanded,
|
||||||
full,
|
full,
|
||||||
);
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 具体错误文本(如 provider 的 HTTP 400 详情)。
|
// 具体错误文本(如 provider 的 HTTP 400 详情)。
|
||||||
// The concrete error text (e.g. a provider HTTP 400 detail).
|
// The concrete error text (e.g. a provider HTTP 400 detail).
|
||||||
|
|
@ -411,17 +439,9 @@ pub fn build_blocks(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::ToolResult(t) => {
|
Message::ToolResult(_) => {
|
||||||
let id = BlockId(format!("m{}-r", mi));
|
// 已合并进对应的工具调用块,不再单独渲染。
|
||||||
out.push(UiBlock::ToolResult {
|
// Already merged into its tool-call block; not rendered alone.
|
||||||
id: id.clone(),
|
|
||||||
name: t.tool_name.clone(),
|
|
||||||
text: join_text(&t.content),
|
|
||||||
is_error: t.is_error,
|
|
||||||
duration_ms: tool_durations.get(&(mi, 0)).copied(),
|
|
||||||
expanded: expanded.contains(&id),
|
|
||||||
full: full.contains(&id),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -464,14 +484,14 @@ pub fn build_blocks(
|
||||||
.filter(|(_, e)| e.status == ExecStatus::Running)
|
.filter(|(_, e)| e.status == ExecStatus::Running)
|
||||||
{
|
{
|
||||||
let id = BlockId(format!("exec-{}", ei));
|
let id = BlockId(format!("exec-{}", ei));
|
||||||
out.push(UiBlock::ToolExec {
|
out.push(UiBlock::Tool {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: exec.name.clone(),
|
name: exec.name.clone(),
|
||||||
summary: exec.summary.clone(),
|
summary: exec.summary.clone(),
|
||||||
|
args: JsonValue::obj(),
|
||||||
status: exec.status,
|
status: exec.status,
|
||||||
duration_ms: exec.duration_ms(),
|
duration_ms: exec.duration_ms(),
|
||||||
output: exec.output.clone(),
|
output: exec.output.clone(),
|
||||||
result_text: exec.result_text.clone(),
|
|
||||||
is_error: exec.is_error,
|
is_error: exec.is_error,
|
||||||
expanded: expanded.contains(&id),
|
expanded: expanded.contains(&id),
|
||||||
full: full.contains(&id),
|
full: full.contains(&id),
|
||||||
|
|
@ -507,14 +527,7 @@ fn push_content_block(
|
||||||
expanded: is_expanded,
|
expanded: is_expanded,
|
||||||
full: is_full,
|
full: is_full,
|
||||||
}),
|
}),
|
||||||
ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall {
|
ContentBlock::Image(_) | ContentBlock::ToolCall(_) => {}
|
||||||
id,
|
|
||||||
name: tc.name.clone(),
|
|
||||||
summary: crate::format::summarize_tool(&tc.name, &tc.arguments),
|
|
||||||
args: tc.arguments.clone(),
|
|
||||||
expanded: is_expanded,
|
|
||||||
}),
|
|
||||||
ContentBlock::Image(_) => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -547,9 +560,7 @@ impl UiBlock {
|
||||||
| UiBlock::AssistantHeader { id, .. }
|
| UiBlock::AssistantHeader { id, .. }
|
||||||
| UiBlock::Text { id, .. }
|
| UiBlock::Text { id, .. }
|
||||||
| UiBlock::Thinking { id, .. }
|
| UiBlock::Thinking { id, .. }
|
||||||
| UiBlock::ToolCall { id, .. }
|
| UiBlock::Tool { id, .. }
|
||||||
| UiBlock::ToolResult { id, .. }
|
|
||||||
| UiBlock::ToolExec { id, .. }
|
|
||||||
| UiBlock::Error { id, .. } => Some(id),
|
| UiBlock::Error { id, .. } => Some(id),
|
||||||
UiBlock::Note { .. } => None,
|
UiBlock::Note { .. } => None,
|
||||||
}
|
}
|
||||||
|
|
@ -558,12 +569,6 @@ impl UiBlock {
|
||||||
/// 是否可展开/折叠(点击或 Tab)。
|
/// 是否可展开/折叠(点击或 Tab)。
|
||||||
/// Whether the block is expandable (click or Tab).
|
/// Whether the block is expandable (click or Tab).
|
||||||
pub fn is_expandable(&self) -> bool {
|
pub fn is_expandable(&self) -> bool {
|
||||||
matches!(
|
matches!(self, UiBlock::Thinking { .. } | UiBlock::Tool { .. })
|
||||||
self,
|
|
||||||
UiBlock::Thinking { .. }
|
|
||||||
| UiBlock::ToolCall { .. }
|
|
||||||
| UiBlock::ToolResult { .. }
|
|
||||||
| UiBlock::ToolExec { .. }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = lines.len() as u16;
|
let total = lines.len() as u16;
|
||||||
|
app.content_lines = total;
|
||||||
let max_scroll = total.saturating_sub(area.height.max(1));
|
let max_scroll = total.saturating_sub(area.height.max(1));
|
||||||
if app.follow {
|
if app.follow {
|
||||||
app.scroll = max_scroll;
|
app.scroll = max_scroll;
|
||||||
|
|
@ -281,94 +282,55 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiBlock::ToolCall {
|
|
||||||
name,
|
UiBlock::Tool {
|
||||||
|
name: _,
|
||||||
summary,
|
summary,
|
||||||
args,
|
args,
|
||||||
expanded,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if *expanded {
|
|
||||||
out.push(Line::from(Span::styled(
|
|
||||||
format!("▾ 🔧 {}", name),
|
|
||||||
Style::new().fg(Color::Magenta).bold(),
|
|
||||||
)));
|
|
||||||
// 人类可读的参数(非原始 JSON)。
|
|
||||||
// Human-readable arguments (not raw JSON).
|
|
||||||
for line in format_args_human(args) {
|
|
||||||
for wrapped in wrap_text(&line, width.saturating_sub(2)) {
|
|
||||||
out.push(Line::from(Span::styled(format!(" {}", wrapped), dim)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push(Line::from(Span::styled(
|
|
||||||
format!("▸ 🔧 {}(点击/Tab 展开)", summary),
|
|
||||||
Style::new().fg(Color::Magenta),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiBlock::ToolResult {
|
|
||||||
name,
|
|
||||||
text,
|
|
||||||
is_error,
|
|
||||||
duration_ms,
|
|
||||||
expanded,
|
|
||||||
full,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mark = if *is_error { "✗" } else { "✓" };
|
|
||||||
let dur = duration_ms
|
|
||||||
.map(format_duration)
|
|
||||||
.map(|d| format!(" · {}", d))
|
|
||||||
.unwrap_or_default();
|
|
||||||
if *expanded {
|
|
||||||
out.push(Line::from(Span::styled(
|
|
||||||
format!("▾ 📦 {} {}{}", mark, name, dur),
|
|
||||||
if *is_error {
|
|
||||||
Style::new().fg(Color::Red).bold()
|
|
||||||
} else {
|
|
||||||
Style::new().fg(Color::Green).bold()
|
|
||||||
},
|
|
||||||
)));
|
|
||||||
out.extend(preview_lines(text, *full, width, " ", dim));
|
|
||||||
} else {
|
|
||||||
out.push(Line::from(Span::styled(
|
|
||||||
format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur),
|
|
||||||
if *is_error {
|
|
||||||
Style::new().fg(Color::Red)
|
|
||||||
} else {
|
|
||||||
Style::new().fg(Color::Green)
|
|
||||||
},
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiBlock::ToolExec {
|
|
||||||
summary,
|
|
||||||
status,
|
status,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
output,
|
output,
|
||||||
result_text,
|
|
||||||
is_error,
|
is_error,
|
||||||
expanded,
|
expanded,
|
||||||
full,
|
full,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let running = *status == ExecStatus::Running;
|
let running = *status == ExecStatus::Running;
|
||||||
if *expanded {
|
let mark = if running {
|
||||||
let state = if running { "● 运行中" } else { "✓" };
|
"● 运行中".to_string()
|
||||||
out.push(Line::from(Span::styled(
|
} else if *is_error {
|
||||||
format!("▾ 🔧 {} · {}", summary, state),
|
"✗".to_string()
|
||||||
Style::new().fg(Color::Magenta).bold(),
|
|
||||||
)));
|
|
||||||
// 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。
|
|
||||||
// Running: live streaming preview; done: result text
|
|
||||||
// (partial/full).
|
|
||||||
let body = if running {
|
|
||||||
output.clone()
|
|
||||||
} else {
|
} else {
|
||||||
result_text.clone()
|
"✓".to_string()
|
||||||
};
|
};
|
||||||
out.extend(preview_lines(&body, *full, width, " ", dim));
|
let dur = duration_ms
|
||||||
|
.map(format_duration)
|
||||||
|
.map(|d| format!(" · {}", d))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if *expanded {
|
||||||
|
let header = format!("▾ 🔧 {} · {} {}", summary, mark, dur);
|
||||||
|
let style = if *is_error {
|
||||||
|
Style::new().fg(Color::Red).bold()
|
||||||
|
} else {
|
||||||
|
Style::new().fg(Color::Magenta).bold()
|
||||||
|
};
|
||||||
|
out.push(Line::from(Span::styled(header, style)));
|
||||||
|
// 参数:人类可读键值对(在那请求)。
|
||||||
|
// Arguments: human-readable key-value lines (the request).
|
||||||
|
for line in format_args_human(args) {
|
||||||
|
for wrapped in wrap_text(&line, width.saturating_sub(2)) {
|
||||||
|
out.push(Line::from(Span::styled(format!(" {}", wrapped), dim)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !output.is_empty() {
|
||||||
|
out.push(Line::from(Span::styled(
|
||||||
|
" ── 结果 ──",
|
||||||
|
Style::new().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
// 结果:部分/全部(在那返回)。
|
||||||
|
// Result: partial/full (the return).
|
||||||
|
out.extend(preview_lines(output, *full, width, " ", dim));
|
||||||
|
}
|
||||||
if !running && *is_error {
|
if !running && *is_error {
|
||||||
out.push(Line::from(Span::styled(
|
out.push(Line::from(Span::styled(
|
||||||
" ✗ 工具执行失败",
|
" ✗ 工具执行失败",
|
||||||
|
|
|
||||||
|
|
@ -93,17 +93,10 @@ fn new_session_resets_state() {
|
||||||
/// back, killing the wheel).
|
/// back, killing the wheel).
|
||||||
#[test]
|
#[test]
|
||||||
fn wheel_scrolls_line_by_line_in_correct_direction() {
|
fn wheel_scrolls_line_by_line_in_correct_direction() {
|
||||||
use focus_tui::app::BlockGeometry;
|
|
||||||
use focus_tui::state::BlockId;
|
|
||||||
|
|
||||||
let mut app = app_on(&temp_data_dir("wheel"));
|
let mut app = app_on(&temp_data_dir("wheel"));
|
||||||
// 模拟内容 100 行、视口 10 行的几何。
|
// 模拟内容 100 行、视口 10 行的几何。
|
||||||
// Simulate geometry: 100 content lines, 10-line viewport.
|
// Simulate geometry: 100 content lines, 10-line viewport.
|
||||||
app.geometry = vec![BlockGeometry {
|
app.content_lines = 100;
|
||||||
id: BlockId("x".into()),
|
|
||||||
start: 0,
|
|
||||||
end: 100,
|
|
||||||
}];
|
|
||||||
app.view_height = 10;
|
app.view_height = 10;
|
||||||
|
|
||||||
// 贴底时向上滚:偏移减 1、取消 follow。
|
// 贴底时向上滚:偏移减 1、取消 follow。
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
|
||||||
assert_eq!(text, "secret reasoning");
|
assert_eq!(text, "secret reasoning");
|
||||||
assert_eq!(*duration_ms, None);
|
assert_eq!(*duration_ms, None);
|
||||||
}
|
}
|
||||||
UiBlock::ToolCall {
|
UiBlock::Tool {
|
||||||
summary, expanded, ..
|
summary, expanded, ..
|
||||||
} => {
|
} => {
|
||||||
assert!(!expanded);
|
assert!(!expanded);
|
||||||
|
|
@ -202,7 +202,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
|
||||||
);
|
);
|
||||||
let mut expanded_count = 0;
|
let mut expanded_count = 0;
|
||||||
for b in &blocks {
|
for b in &blocks {
|
||||||
if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b {
|
if let UiBlock::Thinking { expanded, .. } | UiBlock::Tool { expanded, .. } = b {
|
||||||
assert!(*expanded);
|
assert!(*expanded);
|
||||||
expanded_count += 1;
|
expanded_count += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -346,20 +346,27 @@ fn commit_partial_clears_live_message() {
|
||||||
assert!(rs.partial.is_none());
|
assert!(rs.partial.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已提交 tool_result 消息的耗时进入摘要行。
|
/// 工具结果与调用合并为一块时,耗时进入该块。
|
||||||
/// A committed tool_result's duration reaches its summary.
|
/// When a tool result merges with its call, the duration reaches the block.
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_duration_shows_in_tool_result_block() {
|
fn tool_duration_shows_in_merged_tool_block() {
|
||||||
let transcript = vec![Message::ToolResult(ToolResultMessage {
|
let transcript = vec![
|
||||||
|
Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: "shell".into(),
|
||||||
|
arguments: JsonValue::obj(),
|
||||||
|
})])),
|
||||||
|
Message::ToolResult(ToolResultMessage {
|
||||||
tool_call_id: "c1".into(),
|
tool_call_id: "c1".into(),
|
||||||
tool_name: "shell".into(),
|
tool_name: "shell".into(),
|
||||||
content: vec![ContentBlock::text("ok")],
|
content: vec![ContentBlock::text("ok")],
|
||||||
details: JsonValue::obj(),
|
details: JsonValue::obj(),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
})];
|
}),
|
||||||
|
];
|
||||||
let mut durations = HashMap::new();
|
let mut durations = HashMap::new();
|
||||||
durations.insert((0usize, 0usize), 12345u64);
|
durations.insert((1usize, 0usize), 12345u64); // tool_result 在索引 1 / at index 1
|
||||||
let blocks = build_blocks(
|
let blocks = build_blocks(
|
||||||
&transcript,
|
&transcript,
|
||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
|
|
@ -369,7 +376,7 @@ fn tool_duration_shows_in_tool_result_block() {
|
||||||
&HashSet::new(),
|
&HashSet::new(),
|
||||||
);
|
);
|
||||||
let tr = blocks.iter().find_map(|b| match b {
|
let tr = blocks.iter().find_map(|b| match b {
|
||||||
UiBlock::ToolResult { duration_ms, .. } => Some(*duration_ms),
|
UiBlock::Tool { duration_ms, .. } => Some(*duration_ms),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
assert_eq!(tr, Some(Some(12_345)));
|
assert_eq!(tr, Some(Some(12_345)));
|
||||||
|
|
@ -406,7 +413,7 @@ fn live_renders_only_running_execs() {
|
||||||
&HashSet::new(),
|
&HashSet::new(),
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })),
|
!blocks.iter().any(|b| matches!(b, UiBlock::Tool { .. })),
|
||||||
"finished execs must not render live"
|
"finished execs must not render live"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -430,7 +437,7 @@ fn live_renders_only_running_execs() {
|
||||||
assert!(
|
assert!(
|
||||||
blocks.iter().any(|b| matches!(
|
blocks.iter().any(|b| matches!(
|
||||||
b,
|
b,
|
||||||
UiBlock::ToolExec {
|
UiBlock::Tool {
|
||||||
status: ExecStatus::Running,
|
status: ExecStatus::Running,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
|
|
@ -466,20 +473,27 @@ fn expansion_cycles_three_states() {
|
||||||
assert!(!full.contains(&id));
|
assert!(!full.contains(&id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 二级展开标志进入 ToolResult 块。
|
/// 二级展开标志进入合并后的工具块。
|
||||||
/// The full-level flag reaches ToolResult blocks.
|
/// The full-level flag reaches the merged tool block.
|
||||||
#[test]
|
#[test]
|
||||||
fn full_flag_reaches_blocks() {
|
fn full_flag_reaches_merged_tool_block() {
|
||||||
let transcript = vec![Message::ToolResult(ToolResultMessage {
|
let transcript = vec![
|
||||||
|
Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: "shell".into(),
|
||||||
|
arguments: JsonValue::obj(),
|
||||||
|
})])),
|
||||||
|
Message::ToolResult(ToolResultMessage {
|
||||||
tool_call_id: "c1".into(),
|
tool_call_id: "c1".into(),
|
||||||
tool_name: "shell".into(),
|
tool_name: "shell".into(),
|
||||||
content: vec![ContentBlock::text("out")],
|
content: vec![ContentBlock::text("out")],
|
||||||
details: JsonValue::obj(),
|
details: JsonValue::obj(),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
})];
|
}),
|
||||||
|
];
|
||||||
let mut full = HashSet::new();
|
let mut full = HashSet::new();
|
||||||
full.insert(BlockId("m0-r".into()));
|
full.insert(BlockId("m0-0".into())); // 工具调用在 assistant 的索引 0
|
||||||
let blocks = build_blocks(
|
let blocks = build_blocks(
|
||||||
&transcript,
|
&transcript,
|
||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
|
|
@ -491,9 +505,9 @@ fn full_flag_reaches_blocks() {
|
||||||
let tr = blocks
|
let tr = blocks
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|b| match b {
|
.find_map(|b| match b {
|
||||||
UiBlock::ToolResult { full, .. } => Some(*full),
|
UiBlock::Tool { full, .. } => Some(*full),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("tool result block");
|
.expect("merged tool block");
|
||||||
assert!(tr, "full flag must propagate");
|
assert!(tr, "full flag must propagate");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue