//! 后台任务的测试:运行、自动压缩、仅压缩(注入假 provider,零网络)。 //! Background-job tests: running, auto-compaction, compact-only (injected fake //! provider, zero network). mod fake; use focus_core::model::*; use focus_tui::config::TuiConfig; use focus_tui::job::{JobEvent, RunJob}; use std::path::PathBuf; use std::sync::mpsc::{channel, Receiver}; fn ready_config(window: Option) -> TuiConfig { TuiConfig { api_key: "sk-test".into(), model: "gpt-4o".into(), context_window: window, ..TuiConfig::default() } } /// 运行任务并收集事件,直到 Done。 /// Run the job and collect events until Done. fn collect(job: RunJob) -> Vec { let (tx, rx): (_, Receiver) = channel(); let handle = focus_tui::job::spawn(job, tx); let mut events = Vec::new(); while let Ok(ev) = rx.recv() { let done = matches!(ev, JobEvent::Done { .. }); events.push(ev); if done { break; } } let _ = handle.join(); events } fn done_of(events: &[JobEvent]) -> (&Vec, &Vec, usize, Option<&str>) { match events.iter().rev().find_map(|e| match e { JobEvent::Done { messages, to_persist, appended_count, error, } => Some((messages, to_persist, *appended_count, error.as_deref())), _ => None, }) { Some(x) => x, None => panic!("no Done event"), } } fn long_transcript(n: usize) -> Vec { (0..n) .map(|i| Message::user_text(format!("msg {} {}", i, "x".repeat(200)))) .collect() } #[test] fn run_appends_user_and_assistant() { let job = RunJob { transcript: Vec::new(), user_text: Some("hi".into()), config: ready_config(None), cwd: PathBuf::from("."), system_prompt: "sys".into(), provider: Some(Box::new(fake::FakeProvider::new("agent reply", "SUMMARY"))), }; let events = collect(job); let (messages, to_persist, appended, error) = done_of(&events); assert_eq!(error, None); assert_eq!(messages.len(), 2); assert!(matches!(&messages[0], Message::User(_))); let last = messages.last().unwrap(); let text = match last { Message::Assistant(a) => a.content[0].as_text().map(|t| t.text.clone()), _ => None, }; assert_eq!(text.as_deref(), Some("agent reply")); // 用户消息由任务统一追加并计入 to_persist。 // The user message is appended by the job and counted in to_persist. assert_eq!(to_persist.len(), 2); assert_eq!(appended, 2); // 转发了 agent 事件。 // Agent events were forwarded. assert!(events.iter().any(|e| matches!(e, JobEvent::Agent(_)))); } #[test] fn auto_compaction_then_run() { // 超过阈值(窗口 100,transcript 约 1000+ token)。 // Over the threshold (window 100, transcript ~1000+ tokens). let transcript = long_transcript(10); let job = RunJob { transcript, user_text: Some("next".into()), config: ready_config(Some(100)), cwd: PathBuf::from("."), system_prompt: "sys".into(), provider: Some(Box::new(fake::FakeProvider::new( "agent reply", "SUMMARY TEXT", ))), }; let events = collect(job); let (messages, to_persist, appended, error) = done_of(&events); assert_eq!(error, None); // 最终 transcript 以摘要 user 消息开头。 // The final transcript starts with a summary user message. let first = &messages[0]; let summary_text = match first { Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()), _ => None, }; assert!( summary_text .as_deref() .unwrap_or("") .contains("SUMMARY TEXT"), "expected summary in transcript head, got {:?}", summary_text ); // 末尾是 user("next") + assistant("agent reply")。 // The tail is user("next") + assistant("agent reply"). let n = messages.len(); assert!(matches!(&messages[n - 2], Message::User(_))); let last_text = match &messages[n - 1] { Message::Assistant(a) => a.content[0].as_text().map(|t| t.text.clone()), _ => None, }; assert_eq!(last_text.as_deref(), Some("agent reply")); // to_persist = 摘要 + 本轮追加(user + assistant)。 // to_persist = summary + this run's appended (user + assistant). assert_eq!(to_persist.len(), 3); assert_eq!(appended, 2); // 有压缩说明。 // A compaction note was emitted. assert!(events .iter() .any(|e| matches!(e, JobEvent::Note(n) if n.contains("compacting")))); } #[test] fn compact_only_produces_summary() { let transcript = long_transcript(10); let job = RunJob { transcript, user_text: None, config: ready_config(Some(100)), cwd: PathBuf::from("."), system_prompt: "sys".into(), provider: Some(Box::new(fake::FakeProvider::new( "agent reply", "SUMMARY TEXT", ))), }; let events = collect(job); let (messages, to_persist, appended, error) = done_of(&events); assert_eq!(error, None); // 摘要消息出现在头部且要被持久化。 // The summary message heads the transcript and is persisted. assert!(messages[0] .as_user_text() .map(|t| t.contains("SUMMARY TEXT")) .unwrap_or(false)); assert_eq!(to_persist.len(), 1); assert_eq!(appended, 0); } #[test] fn below_threshold_no_compaction() { // 窗口极大 → 不触发压缩。 // Huge window → no compaction. let transcript = long_transcript(3); let job = RunJob { transcript, user_text: Some("hi".into()), config: ready_config(Some(1_000_000)), cwd: PathBuf::from("."), system_prompt: "sys".into(), provider: Some(Box::new(fake::FakeProvider::new("agent reply", "SUMMARY"))), }; let events = collect(job); assert!(!events .iter() .any(|e| matches!(e, JobEvent::Note(n) if n.contains("compacting")))); let (messages, to_persist, appended, _) = done_of(&events); assert_eq!(messages.len(), 5); // 3 user + user("hi") + assistant assert_eq!(to_persist.len(), 2); assert_eq!(appended, 2); } /// 为 `Message` 提供便捷的文本访问(测试辅助)。 /// Convenience text accessor on `Message` (test helper). trait AsUserText { fn as_user_text(&self) -> Option; } impl AsUserText for Message { fn as_user_text(&self) -> Option { match self { Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()), _ => None, } } }