305 lines
9.7 KiB
Rust
305 lines
9.7 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(), None, &expanded);
|
||
|
||
// 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。
|
||
// 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::ToolCall {
|
||
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(), None, &expanded);
|
||
let mut expanded_count = 0;
|
||
for b in &blocks {
|
||
if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { 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(), Some(&rs), &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(), None, &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");
|
||
}
|