606 lines
19 KiB
Rust
606 lines
19 KiB
Rust
//! 纯 UI 状态机的测试:思考计时、工具执行、块构建与折叠。
|
||
//! Tests for the pure UI state machine: thinking timings, tool executions,
|
||
//! block building and collapsing.
|
||
|
||
use focus_core::event::AgentEvent;
|
||
use focus_core::model::*;
|
||
use focus_core::tool::{ToolResult, ToolUpdate};
|
||
use focus_json::JsonValue;
|
||
use focus_tui::state::*;
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
/// 可推进的假时钟。
|
||
/// An advanceable fake clock.
|
||
struct FakeClock(std::sync::Arc<AtomicU64>);
|
||
|
||
impl FakeClock {
|
||
fn new() -> Self {
|
||
Self(std::sync::Arc::new(AtomicU64::new(0)))
|
||
}
|
||
fn clock(&self) -> Clock {
|
||
let inner = self.0.clone();
|
||
Arc::new(move || inner.load(Ordering::SeqCst))
|
||
}
|
||
fn advance(&self, ms: u64) {
|
||
self.0.fetch_add(ms, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
fn thinking_block(text: &str) -> ContentBlock {
|
||
ContentBlock::Thinking(ThinkingContent {
|
||
thinking: text.into(),
|
||
signature: None,
|
||
redacted: false,
|
||
})
|
||
}
|
||
|
||
fn assistant(content: Vec<ContentBlock>) -> AssistantMessage {
|
||
AssistantMessage {
|
||
content,
|
||
model: "m".into(),
|
||
usage: Usage::default(),
|
||
stop_reason: StopReason::Stop,
|
||
error_message: None,
|
||
timestamp: 0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn thinking_duration_is_measured() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
|
||
// 思考块出现(t=0)。
|
||
// Thinking block appears (t=0).
|
||
let p1 = assistant(vec![thinking_block("hmm")]);
|
||
rs.on_agent_event(&AgentEvent::MessageStart {
|
||
message: Message::Assistant(p1),
|
||
});
|
||
fc.advance(500);
|
||
// 思考 + 文本(思考结束于 t=500)。
|
||
// Thinking + text (thinking ends at t=500).
|
||
let p2 = assistant(vec![thinking_block("hmm"), ContentBlock::text("answer")]);
|
||
rs.on_agent_event(&AgentEvent::MessageUpdate {
|
||
message: Message::Assistant(p2),
|
||
event_type: "text_start".into(),
|
||
});
|
||
fc.advance(300);
|
||
let p3 = assistant(vec![
|
||
thinking_block("hmm"),
|
||
ContentBlock::text("answer extended"),
|
||
]);
|
||
rs.on_agent_event(&AgentEvent::MessageUpdate {
|
||
message: Message::Assistant(p3.clone()),
|
||
event_type: "text_delta".into(),
|
||
});
|
||
rs.on_agent_event(&AgentEvent::MessageEnd {
|
||
message: Message::Assistant(p3.clone()),
|
||
});
|
||
rs.finalize();
|
||
|
||
let timing = rs.thinking.get(&(0, 0)).expect("thinking timing");
|
||
assert_eq!(timing.duration_ms(), 500);
|
||
}
|
||
|
||
#[test]
|
||
fn tool_execution_tracks_summary_output_and_duration() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
|
||
let mut args = JsonValue::obj();
|
||
args.insert("command", "cargo test".into()).ok();
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||
tool_call_id: "call_1".into(),
|
||
tool_name: "shell".into(),
|
||
args,
|
||
});
|
||
assert_eq!(rs.tool_execs.len(), 1);
|
||
assert_eq!(rs.tool_execs[0].status, ExecStatus::Running);
|
||
assert_eq!(rs.tool_execs[0].summary, "shell: cargo test");
|
||
|
||
fc.advance(100);
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionUpdate {
|
||
tool_call_id: "call_1".into(),
|
||
tool_name: "shell".into(),
|
||
partial_result: ToolUpdate {
|
||
content: vec![ContentBlock::text("compiling…")],
|
||
details: JsonValue::obj(),
|
||
},
|
||
});
|
||
fc.advance(100);
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionUpdate {
|
||
tool_call_id: "call_1".into(),
|
||
tool_name: "shell".into(),
|
||
partial_result: ToolUpdate {
|
||
content: vec![ContentBlock::text("2 passed")],
|
||
details: JsonValue::obj(),
|
||
},
|
||
});
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionEnd {
|
||
tool_call_id: "call_1".into(),
|
||
tool_name: "shell".into(),
|
||
result: ToolResult::text("2 passed"),
|
||
is_error: false,
|
||
});
|
||
|
||
let exec = &rs.tool_execs[0];
|
||
assert_eq!(exec.status, ExecStatus::Done);
|
||
assert_eq!(exec.duration_ms(), Some(200));
|
||
assert_eq!(exec.output, "compiling…\n2 passed");
|
||
assert_eq!(exec.result_text, "2 passed");
|
||
assert!(!exec.is_error);
|
||
}
|
||
|
||
#[test]
|
||
fn blocks_are_collapsed_by_default_and_summarized() {
|
||
let mut expanded = HashSet::new();
|
||
let transcript = vec![
|
||
Message::user_text("hi"),
|
||
Message::Assistant(assistant(vec![
|
||
thinking_block("secret reasoning"),
|
||
ContentBlock::ToolCall(ToolCall {
|
||
id: "c1".into(),
|
||
name: "read".into(),
|
||
arguments: {
|
||
let mut a = JsonValue::obj();
|
||
a.insert("path", "src/main.rs".into()).ok();
|
||
a
|
||
},
|
||
}),
|
||
])),
|
||
];
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
None,
|
||
&expanded,
|
||
&HashSet::new(),
|
||
);
|
||
|
||
// 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。
|
||
// Collapsed: thinking shows only its summary; tool calls show only their
|
||
// summary.
|
||
for b in &blocks {
|
||
match b {
|
||
UiBlock::Thinking {
|
||
text,
|
||
expanded,
|
||
duration_ms,
|
||
..
|
||
} => {
|
||
assert!(!expanded);
|
||
assert_eq!(text, "secret reasoning");
|
||
assert_eq!(*duration_ms, None);
|
||
}
|
||
UiBlock::Tool {
|
||
summary, expanded, ..
|
||
} => {
|
||
assert!(!expanded);
|
||
assert_eq!(summary, "read src/main.rs");
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// 展开后:expanded 标记生效。
|
||
// After expanding: the expanded flag takes effect.
|
||
for b in &blocks {
|
||
if let Some(id) = b.id() {
|
||
toggle_expanded(&mut expanded, id);
|
||
}
|
||
}
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
None,
|
||
&expanded,
|
||
&HashSet::new(),
|
||
);
|
||
let mut expanded_count = 0;
|
||
for b in &blocks {
|
||
if let UiBlock::Thinking { expanded, .. } | UiBlock::Tool { expanded, .. } = b {
|
||
assert!(*expanded);
|
||
expanded_count += 1;
|
||
}
|
||
}
|
||
assert_eq!(expanded_count, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_includes_live_run() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
rs.on_agent_event(&AgentEvent::MessageStart {
|
||
message: Message::Assistant(assistant(vec![thinking_block("why")])),
|
||
});
|
||
fc.advance(250);
|
||
rs.on_agent_event(&AgentEvent::MessageUpdate {
|
||
message: Message::Assistant(assistant(vec![
|
||
thinking_block("why"),
|
||
ContentBlock::text("because"),
|
||
])),
|
||
event_type: "text_start".into(),
|
||
});
|
||
|
||
let blocks = build_blocks(
|
||
&[],
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
Some(&rs),
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
// live 头 + 思考 + 文本。
|
||
// live header + thinking + text.
|
||
let thinking = blocks
|
||
.iter()
|
||
.find_map(|b| match b {
|
||
UiBlock::Thinking {
|
||
duration_ms, text, ..
|
||
} => Some((text.clone(), *duration_ms)),
|
||
_ => None,
|
||
})
|
||
.expect("live thinking block");
|
||
assert_eq!(thinking.0, "why");
|
||
// 思考块结束后有文本 → 已有耗时。
|
||
// A later block appeared → the duration is settled.
|
||
assert_eq!(thinking.1, Some(250));
|
||
}
|
||
|
||
/// working 阶段检测:运行中的工具 > 思考中 > 流式输出。
|
||
/// Working-phase detection: running tool > thinking > streaming.
|
||
#[test]
|
||
fn working_phase_detection() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
// 无状态 → 一般工作。
|
||
// No state → generic work.
|
||
assert_eq!(rs.working_phase(), "working…");
|
||
|
||
// 思考中。
|
||
// Thinking.
|
||
rs.on_agent_event(&AgentEvent::MessageStart {
|
||
message: Message::Assistant(assistant(vec![thinking_block("hmm")])),
|
||
});
|
||
assert_eq!(rs.working_phase(), "thinking…");
|
||
|
||
// 流式输出(思考后出现文本)。
|
||
// Streaming (text after thinking).
|
||
rs.on_agent_event(&AgentEvent::MessageUpdate {
|
||
message: Message::Assistant(assistant(vec![
|
||
thinking_block("hmm"),
|
||
ContentBlock::text("answer"),
|
||
])),
|
||
event_type: "text_start".into(),
|
||
});
|
||
assert_eq!(rs.working_phase(), "streaming…");
|
||
|
||
// 运行中的工具优先。
|
||
// A running tool takes precedence.
|
||
let mut args = JsonValue::obj();
|
||
args.insert("command", "cargo test".into()).ok();
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
args,
|
||
});
|
||
assert_eq!(rs.working_phase(), "shell: cargo test…");
|
||
|
||
// 工具结束后回到流式。
|
||
// Back to streaming after the tool finishes.
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionEnd {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
result: ToolResult::text("ok"),
|
||
is_error: false,
|
||
});
|
||
assert_eq!(rs.working_phase(), "streaming…");
|
||
}
|
||
|
||
/// 错误消息必须渲染出具体的错误文本(而不是只有消息头)。
|
||
/// An error message must render its concrete error text (not just a header).
|
||
#[test]
|
||
fn error_message_renders_as_error_block() {
|
||
let transcript = vec![Message::Assistant(AssistantMessage {
|
||
content: Vec::new(),
|
||
model: "m".into(),
|
||
usage: Usage::default(),
|
||
stop_reason: StopReason::Error,
|
||
error_message: Some("http error 400: bad request detail".into()),
|
||
timestamp: 0,
|
||
})];
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
None,
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
let err = blocks
|
||
.iter()
|
||
.find_map(|b| match b {
|
||
UiBlock::Error { text, .. } => Some(text.clone()),
|
||
_ => None,
|
||
})
|
||
.expect("error block");
|
||
assert_eq!(err, "http error 400: bad request detail");
|
||
}
|
||
|
||
/// commit_partial 清空流式 partial(消息已提交进 transcript 后避免重复显示)。
|
||
/// commit_partial clears the streaming partial (avoids double-rendering once
|
||
/// the message is committed into the transcript).
|
||
#[test]
|
||
fn commit_partial_clears_live_message() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
rs.on_agent_event(&AgentEvent::MessageStart {
|
||
message: Message::Assistant(assistant(vec![ContentBlock::text("hi")])),
|
||
});
|
||
assert!(rs.partial.is_some());
|
||
rs.commit_partial();
|
||
assert!(rs.partial.is_none());
|
||
}
|
||
|
||
/// 工具结果与调用合并为一块时,耗时进入该块。
|
||
/// When a tool result merges with its call, the duration reaches the block.
|
||
#[test]
|
||
fn tool_duration_shows_in_merged_tool_block() {
|
||
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_name: "shell".into(),
|
||
content: vec![ContentBlock::text("ok")],
|
||
details: JsonValue::obj(),
|
||
is_error: false,
|
||
timestamp: 0,
|
||
}),
|
||
];
|
||
let mut durations = HashMap::new();
|
||
durations.insert((1usize, 0usize), 12345u64); // tool_result 在索引 1 / at index 1
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&durations,
|
||
None,
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
let tr = blocks.iter().find_map(|b| match b {
|
||
UiBlock::Tool { duration_ms, .. } => Some(*duration_ms),
|
||
_ => None,
|
||
});
|
||
assert_eq!(tr, Some(Some(12_345)));
|
||
}
|
||
|
||
/// 流式视图只显示运行中的工具执行(已结束的由 transcript 呈现)。
|
||
/// The live view only shows running tool executions (finished ones are
|
||
/// presented by the transcript).
|
||
#[test]
|
||
fn live_renders_only_running_execs() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
let mut args = JsonValue::obj();
|
||
args.insert("command", "cargo test".into()).ok();
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
args,
|
||
});
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionEnd {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
result: ToolResult::text("done"),
|
||
is_error: false,
|
||
});
|
||
// 全部结束 → 不渲染任何执行块。
|
||
// All finished → no execution blocks rendered.
|
||
let blocks = build_blocks(
|
||
&[],
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
Some(&rs),
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
assert!(
|
||
!blocks.iter().any(|b| matches!(b, UiBlock::Tool { .. })),
|
||
"finished execs must not render live"
|
||
);
|
||
|
||
// 一个新的运行中工具 → 渲染。
|
||
// A new running tool → rendered.
|
||
let mut args = JsonValue::obj();
|
||
args.insert("command", "sleep 1".into()).ok();
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||
tool_call_id: "c2".into(),
|
||
tool_name: "shell".into(),
|
||
args,
|
||
});
|
||
let blocks = build_blocks(
|
||
&[],
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
Some(&rs),
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
assert!(
|
||
blocks.iter().any(|b| matches!(
|
||
b,
|
||
UiBlock::Tool {
|
||
status: ExecStatus::Running,
|
||
..
|
||
}
|
||
)),
|
||
"running exec must render"
|
||
);
|
||
}
|
||
|
||
/// 三态展开循环:折叠 → 部分 → 全部 → 折叠。
|
||
/// Three-state expansion cycle: collapsed → partial → full → collapsed.
|
||
#[test]
|
||
fn expansion_cycles_three_states() {
|
||
let id = BlockId("live-0".into());
|
||
let mut expanded = HashSet::new();
|
||
let mut full = HashSet::new();
|
||
|
||
// 折叠 → 部分。
|
||
// Collapsed → partial.
|
||
cycle_expansion(&mut expanded, &mut full, &id);
|
||
assert!(expanded.contains(&id));
|
||
assert!(!full.contains(&id));
|
||
|
||
// 部分 → 全部。
|
||
// Partial → full.
|
||
cycle_expansion(&mut expanded, &mut full, &id);
|
||
assert!(!expanded.contains(&id));
|
||
assert!(full.contains(&id));
|
||
|
||
// 全部 → 折叠。
|
||
// Full → collapsed.
|
||
cycle_expansion(&mut expanded, &mut full, &id);
|
||
assert!(!expanded.contains(&id));
|
||
assert!(!full.contains(&id));
|
||
}
|
||
|
||
/// 二级展开标志进入合并后的工具块。
|
||
/// The full-level flag reaches the merged tool block.
|
||
#[test]
|
||
fn full_flag_reaches_merged_tool_block() {
|
||
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_name: "shell".into(),
|
||
content: vec![ContentBlock::text("out")],
|
||
details: JsonValue::obj(),
|
||
is_error: false,
|
||
timestamp: 0,
|
||
}),
|
||
];
|
||
let mut full = HashSet::new();
|
||
full.insert(BlockId("m0-0".into())); // 工具调用在 assistant 的索引 0
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
None,
|
||
&HashSet::new(),
|
||
&full,
|
||
);
|
||
let tr = blocks
|
||
.iter()
|
||
.find_map(|b| match b {
|
||
UiBlock::Tool { full, .. } => Some(*full),
|
||
_ => None,
|
||
})
|
||
.expect("merged tool block");
|
||
assert!(tr, "full flag must propagate");
|
||
}
|
||
|
||
/// 流式消息中的工具调用必须以「待执行」状态渲染(回归:此前被丢弃,
|
||
/// 工具信息要等整个回合输出完才出现)。
|
||
/// Tool calls inside a streaming message must render as pending blocks
|
||
/// (regression: they used to be dropped, appearing only after the turn).
|
||
#[test]
|
||
fn live_partial_renders_pending_tool_calls() {
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
rs.on_agent_event(&AgentEvent::MessageStart {
|
||
message: Message::Assistant(assistant(vec![
|
||
thinking_block("why"),
|
||
ContentBlock::ToolCall(ToolCall {
|
||
id: "call_0".into(),
|
||
name: "read".into(),
|
||
arguments: {
|
||
let mut a = JsonValue::obj();
|
||
a.insert("path", "src/main.rs".into()).ok();
|
||
a
|
||
},
|
||
}),
|
||
])),
|
||
});
|
||
let blocks = build_blocks(
|
||
&[],
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
Some(&rs),
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
let tool = blocks
|
||
.iter()
|
||
.find_map(|b| match b {
|
||
UiBlock::Tool {
|
||
status, summary, ..
|
||
} => Some((*status, summary.clone())),
|
||
_ => None,
|
||
})
|
||
.expect("live tool block");
|
||
assert_eq!(tool.0, ExecStatus::Pending);
|
||
assert_eq!(tool.1, "read src/main.rs");
|
||
}
|
||
|
||
/// 已提交的工具调用在结果未回但执行中时,显示运行状态与流式输出
|
||
/// (状态机:结果 → 完成;运行中 → 实时输出;否则 → 待执行)。
|
||
/// A committed tool call whose result is not back yet shows the running
|
||
/// status and streamed output while executing.
|
||
#[test]
|
||
fn committed_tool_with_running_exec_shows_live_output() {
|
||
let mut args = JsonValue::obj();
|
||
args.insert("command", "cargo build".into()).ok();
|
||
let transcript = vec![Message::Assistant(assistant(vec![ContentBlock::ToolCall(
|
||
ToolCall {
|
||
id: "c1".into(),
|
||
name: "shell".into(),
|
||
arguments: args.clone(),
|
||
},
|
||
)]))];
|
||
let fc = FakeClock::new();
|
||
let mut rs = RunState::new(fc.clock());
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
args,
|
||
});
|
||
rs.on_agent_event(&AgentEvent::ToolExecutionUpdate {
|
||
tool_call_id: "c1".into(),
|
||
tool_name: "shell".into(),
|
||
partial_result: ToolUpdate {
|
||
content: vec![ContentBlock::text("compiling…")],
|
||
details: JsonValue::obj(),
|
||
},
|
||
});
|
||
let blocks = build_blocks(
|
||
&transcript,
|
||
&HashMap::new(),
|
||
&HashMap::new(),
|
||
Some(&rs),
|
||
&HashSet::new(),
|
||
&HashSet::new(),
|
||
);
|
||
let tool = blocks
|
||
.iter()
|
||
.find_map(|b| match b {
|
||
UiBlock::Tool { status, output, .. } => Some((*status, output.clone())),
|
||
_ => None,
|
||
})
|
||
.expect("merged tool block");
|
||
assert_eq!(tool.0, ExecStatus::Running);
|
||
assert_eq!(tool.1, "compiling…");
|
||
}
|