From f695acc845d4d012043f2f2987a6a33bc22fdb1a Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 21:09:01 +0800 Subject: [PATCH] refactor(test): move all unit tests from src/ into tests/_tests.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per AGENTS.md §5.1 every test must live under crates//tests/, so the inline #[cfg(test)] modules in harness/tools/providers/transport are gone: - harness: session_tests / compaction_tests / prompt_tests (days_to_ymd made pub) - tools: read / write / edit / shell tests (Tool trait imported explicitly) - providers: config_tests; request-body and stop-reason coverage folded into integration tests via mock-recorded requests and SSE events (private builders no longer tested directly) - transport: ChunkedDecoder made pub (with Default), decoder tests moved into transport_tests.rs The only #[cfg(test)] left in src/ is the test-only TLS infra in tls.rs. --- crates/focus-harness/src/compaction.rs | 130 --------------- crates/focus-harness/src/prompt.rs | 86 +--------- crates/focus-harness/src/session.rs | 124 -------------- .../focus-harness/tests/compaction_tests.rs | 135 +++++++++++++++ crates/focus-harness/tests/prompt_tests.rs | 83 ++++++++++ crates/focus-harness/tests/session_tests.rs | 124 ++++++++++++++ crates/focus-providers/src/anthropic.rs | 116 ------------- crates/focus-providers/src/config.rs | 46 ------ crates/focus-providers/src/openai.rs | 126 -------------- .../focus-providers/tests/anthropic_tests.rs | 139 ++++++++++++++++ crates/focus-providers/tests/config_tests.rs | 56 +++++++ crates/focus-providers/tests/openai_tests.rs | 155 ++++++++++++++++++ crates/focus-tools/src/edit.rs | 105 ------------ crates/focus-tools/src/read.rs | 80 --------- crates/focus-tools/src/shell.rs | 95 ----------- crates/focus-tools/src/write.rs | 44 ----- crates/focus-tools/tests/edit_tests.rs | 106 ++++++++++++ crates/focus-tools/tests/read_tests.rs | 81 +++++++++ crates/focus-tools/tests/shell_tests.rs | 97 +++++++++++ crates/focus-tools/tests/write_tests.rs | 45 +++++ crates/focus-transport/src/transport.rs | 100 ++--------- .../focus-transport/tests/transport_tests.rs | 70 ++++++++ 22 files changed, 1107 insertions(+), 1036 deletions(-) create mode 100644 crates/focus-harness/tests/compaction_tests.rs create mode 100644 crates/focus-harness/tests/prompt_tests.rs create mode 100644 crates/focus-harness/tests/session_tests.rs create mode 100644 crates/focus-providers/tests/config_tests.rs create mode 100644 crates/focus-tools/tests/edit_tests.rs create mode 100644 crates/focus-tools/tests/read_tests.rs create mode 100644 crates/focus-tools/tests/shell_tests.rs create mode 100644 crates/focus-tools/tests/write_tests.rs diff --git a/crates/focus-harness/src/compaction.rs b/crates/focus-harness/src/compaction.rs index 367088a..d4cc33c 100644 --- a/crates/focus-harness/src/compaction.rs +++ b/crates/focus-harness/src/compaction.rs @@ -225,133 +225,3 @@ pub fn describe_plan(plan: &CompactionPlan) -> String { plan.estimated_saved_tokens, ) } - -#[cfg(test)] -mod tests { - use super::*; - use focus_core::model::*; - use focus_json::JsonValue; - - fn long_user_message(text: &str) -> Message { - Message::user_text(text) - } - - #[test] - fn estimate_is_reasonable() { - // 80 个 ASCII 字符 ≈ 20 token。 - // 80 ASCII chars ≈ 20 tokens. - let text = "a".repeat(80); - assert_eq!(estimate_tokens(&text), 20); - // 中文按字符计。 - // CJK counted per char. - assert_eq!(estimate_tokens("中文"), 2); - // 消息开销计入。 - // Per-message overhead is included. - assert!(estimate_messages(&[long_user_message("hi")]) > estimate_tokens("hi")); - } - - #[test] - fn below_threshold_returns_none() { - let messages: Vec = (0..5) - .map(|i| long_user_message(&format!("msg {}", i))) - .collect(); - // 窗口极大 → 不触发。 - // Huge window → no trigger. - assert!(plan_compaction( - &messages, - 1_000_000, - DEFAULT_THRESHOLD_RATIO, - DEFAULT_KEEP_RATIO - ) - .is_none()); - } - - #[test] - fn above_threshold_plans_summarize_and_keep() { - // 每条消息约 50+ token("x"*200 → 50 token + 4 开销);10 条约 540 token。 - // Each message ≈ 50+ tokens; 10 of them ≈ 540 tokens. - let messages: Vec = (0..10) - .map(|_i| long_user_message(&"x".repeat(200))) - .collect(); - let plan = plan_compaction(&messages, 200, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO) - .expect("plan"); - assert!(!plan.summarize.is_empty()); - assert!(!plan.keep.is_empty()); - assert!(plan.summarize.len() + plan.keep.len() == messages.len()); - // 摘要指令包含两个部分。 - // The summary instruction has both sections. - assert!(plan.summary_instruction.contains("")); - assert!(plan.summary_instruction.contains("")); - // 保存的 token 为正。 - // Saved tokens are positive. - assert!(plan.estimated_saved_tokens > 0); - } - - #[test] - fn keeps_tool_result_pairs_intact() { - // 构造:user → assistant(tool_call) → toolResult → assistant(text), - // 其中 tool 部分恰好落在边界附近。 - // Build: user → assistant(tool_call) → toolResult → assistant(text), - // with the tool part landing near the boundary. - let mut args = JsonValue::obj(); - args.insert("x", "1".into()).ok(); - let mut messages = vec![long_user_message(&"a".repeat(300))]; - messages.push(Message::Assistant(AssistantMessage { - content: vec![ContentBlock::ToolCall(ToolCall { - id: "c1".into(), - name: "read".into(), - arguments: args, - })], - model: "m".into(), - usage: Usage::default(), - stop_reason: StopReason::ToolUse, - error_message: None, - timestamp: 0, - })); - messages.push(Message::ToolResult(ToolResultMessage { - tool_call_id: "c1".into(), - tool_name: "read".into(), - content: vec![ContentBlock::text("r".repeat(300))], - details: JsonValue::obj(), - is_error: false, - timestamp: 0, - })); - messages.push(Message::Assistant(AssistantMessage { - content: vec![ContentBlock::text("final")], - model: "m".into(), - usage: Usage::default(), - stop_reason: StopReason::Stop, - error_message: None, - timestamp: 0, - })); - - let plan = plan_compaction(&messages, 100, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO) - .expect("plan"); - // 若 tool_result 在 keep 中,其 assistant 调用消息必须也在 keep 中。 - // If the tool_result is kept, its assistant call message must be kept too. - let keep: Vec<&Message> = plan.keep.iter().collect(); - if keep.iter().any(|m| matches!(m, Message::ToolResult(_))) { - assert!(keep.iter().any(|m| matches!(m, Message::Assistant(a) if a.content.iter().any(|c| c.as_tool_call().is_some())))); - } - } - - #[test] - fn apply_summary_prepends_summary_message() { - let messages: Vec = (0..6) - .map(|_i| long_user_message(&"x".repeat(200))) - .collect(); - let plan = plan_compaction(&messages, 100, 0.8, 0.6).expect("plan"); - let out = apply_summary("SUMMARY TEXT", &plan); - assert_eq!(out.len(), plan.keep.len() + 1); - match &out[0] { - Message::User(u) => { - let text = u.content[0].as_text().unwrap().text.clone(); - assert!(text.contains("SUMMARY TEXT")); - } - other => panic!("expected user summary, got {:?}", other), - } - // 保留部分原样在后。 - // The kept part follows verbatim. - assert_eq!(out[1..], plan.keep[..]); - } -} diff --git a/crates/focus-harness/src/prompt.rs b/crates/focus-harness/src/prompt.rs index d0fe19d..7cd37db 100644 --- a/crates/focus-harness/src/prompt.rs +++ b/crates/focus-harness/src/prompt.rs @@ -217,9 +217,11 @@ pub fn today_iso() -> String { } /// 把「自 1970-01-01 的天数」转换为 (年, 月, 日)。Howard Hinnant 算法。 +/// 公开暴露以便独立测试;正常使用经 [`today_iso`] 完成。 /// Convert "days since 1970-01-01" into (year, month, day). Howard Hinnant's -/// algorithm. -fn days_to_ymd(days: i64) -> (i64, u32, u32) { +/// algorithm. Exposed publicly so it can be tested standalone; normal use +/// happens through [`today_iso`]. +pub fn days_to_ymd(days: i64) -> (i64, u32, u32) { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; @@ -232,83 +234,3 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { let y = if m <= 2 { y + 1 } else { y }; (y, m, d) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn days_to_ymd_known_dates() { - // 1970-01-01 → 0。 - assert_eq!(days_to_ymd(0), (1970, 1, 1)); - // 2000-01-01。 - // 30 年 * 365.25 ≈ 10957 天。 - assert_eq!(days_to_ymd(10_957), (2000, 1, 1)); - // 2024-02-29(闰年)。 - // 2024-02-29 (leap year). - let days = (2024 - 1970) * 365 + (2024 - 1969) / 4 + 59; - assert_eq!(days_to_ymd(days), (2024, 2, 29)); - } - - #[test] - fn renders_default_template() { - let tpl = SystemPromptTemplate::default(); - let mut ctx = default_context(); - ctx.context_usage = Some(ContextUsage { - estimated_tokens: 12_345, - context_window: 128_000, - window_known: true, - }); - ctx.last_usage = Some(Usage { - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 5, - cache_write_tokens: 0, - }); - ctx.extra.push("remember: rust edition 2021".to_string()); - let out = tpl.render(&ctx); - assert!(out.contains(&ctx.cwd.display().to_string())); - assert!( - out.contains("Context usage: 12,345 / 128,000 tokens (9.6%)."), - "got: {}", - out - ); - assert!(out.contains("Last turn usage: input 100, output 50, cache-read 5, cache-write 0.")); - assert!(out.contains("remember: rust edition 2021")); - assert!(out.contains(ctx.os)); - } - - #[test] - fn renders_unknown_window_note() { - let tpl = SystemPromptTemplate::default(); - let mut ctx = default_context(); - ctx.context_usage = Some(ContextUsage { - estimated_tokens: 50_000, - context_window: 128_000, - window_known: false, - }); - let out = tpl.render(&ctx); - assert!( - out.contains("window unknown; assuming 128,000"), - "got: {}", - out - ); - } - - #[test] - fn missing_fields_render_empty() { - let tpl = SystemPromptTemplate::default(); - let ctx = PromptContext { - cwd: PathBuf::from("/tmp"), - os: "linux", - date: "2025-01-01".into(), - shell: "bash".into(), - context_usage: None, - last_usage: None, - extra: Vec::new(), - }; - let out = tpl.render(&ctx); - assert!(!out.contains("{context_usage}")); - assert!(!out.contains("{last_usage}")); - } -} diff --git a/crates/focus-harness/src/session.rs b/crates/focus-harness/src/session.rs index f043cbb..0aee189 100644 --- a/crates/focus-harness/src/session.rs +++ b/crates/focus-harness/src/session.rs @@ -294,127 +294,3 @@ pub fn ensure_dir(path: &Path) -> CoreResult<()> { std::fs::create_dir_all(path) .map_err(|e| CoreError::Tool(format!("create dir {}: {}", path.display(), e))) } - -#[cfg(test)] -mod tests { - use super::*; - use focus_core::model::Message; - - fn temp_dir(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!( - "focus-harness-session-{}-{}", - tag, - std::process::id() - )); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn appends_and_loads_entries() { - let dir = temp_dir("append"); - let store = SessionStore::new(&dir); - store - .append( - "s1", - &SessionEntry::new("e1".into(), None, Message::user_text("hi")), - ) - .unwrap(); - store - .append( - "s1", - &SessionEntry::new("e2".into(), Some("e1".into()), Message::user_text("there")), - ) - .unwrap(); - - let entries = store.load("s1").unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].id, "e1"); - assert_eq!(entries[0].parent_id, None); - assert_eq!(entries[1].id, "e2"); - assert_eq!(entries[1].parent_id.as_deref(), Some("e1")); - assert!(matches!(entries[0].message, Message::User(_))); - } - - #[test] - fn next_entry_id_is_incremental() { - let dir = temp_dir("seq"); - let store = SessionStore::new(&dir); - assert_eq!(store.next_entry_id("s1").unwrap(), "e1"); - store - .append( - "s1", - &SessionEntry::new("e1".into(), None, Message::user_text("a")), - ) - .unwrap(); - assert_eq!(store.next_entry_id("s1").unwrap(), "e2"); - store - .append( - "s1", - &SessionEntry::new("e3".into(), Some("e1".into()), Message::user_text("b")), - ) - .unwrap(); - // e3 已存在 → 下一个是 e4。 - // e3 exists → the next is e4. - assert_eq!(store.next_entry_id("s1").unwrap(), "e4"); - } - - #[test] - fn lists_sessions() { - let dir = temp_dir("list"); - let store = SessionStore::new(&dir); - store - .append( - "alpha", - &SessionEntry::new("e1".into(), None, Message::user_text("a")), - ) - .unwrap(); - store - .append( - "beta", - &SessionEntry::new("e1".into(), None, Message::user_text("b")), - ) - .unwrap(); - let ids = store.list_sessions().unwrap(); - assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]); - } - - fn tree() -> SessionTree { - let entries = vec![ - SessionEntry::new("e1".into(), None, Message::user_text("root")), - SessionEntry::new( - "e2".into(), - Some("e1".into()), - Message::user_text("child A"), - ), - SessionEntry::new( - "e3".into(), - Some("e1".into()), - Message::user_text("child B"), - ), - SessionEntry::new( - "e4".into(), - Some("e3".into()), - Message::user_text("grandchild"), - ), - ]; - SessionTree::build(entries) - } - - #[test] - fn tree_queries() { - let t = tree(); - assert_eq!(t.roots().len(), 1); - assert_eq!(t.roots()[0].id, "e1"); - let children = t.children_of("e1"); - assert_eq!(children.len(), 2); - let path = t.branch("e4"); - let ids: Vec<&str> = path.iter().map(|e| e.id.as_str()).collect(); - assert_eq!(ids, vec!["e1", "e3", "e4"]); - let leaves = t.leaf_ids(); - assert_eq!(leaves.len(), 2); - assert!(leaves.contains(&"e2")); - assert!(leaves.contains(&"e4")); - } -} diff --git a/crates/focus-harness/tests/compaction_tests.rs b/crates/focus-harness/tests/compaction_tests.rs new file mode 100644 index 0000000..c28427e --- /dev/null +++ b/crates/focus-harness/tests/compaction_tests.rs @@ -0,0 +1,135 @@ +//! 上下文压缩(方案 D+E)的单元测试。 +//! Unit tests for context compaction (schemes D+E). + +use focus_core::model::*; +use focus_harness::compaction::{ + apply_summary, estimate_messages, estimate_tokens, plan_compaction, DEFAULT_KEEP_RATIO, + DEFAULT_THRESHOLD_RATIO, +}; +use focus_json::JsonValue; + +fn long_user_message(text: &str) -> Message { + Message::user_text(text) +} + +#[test] +fn estimate_is_reasonable() { + // 80 个 ASCII 字符 ≈ 20 token。 + // 80 ASCII chars ≈ 20 tokens. + let text = "a".repeat(80); + assert_eq!(estimate_tokens(&text), 20); + // 中文按字符计。 + // CJK counted per char. + assert_eq!(estimate_tokens("中文"), 2); + // 消息开销计入。 + // Per-message overhead is included. + assert!(estimate_messages(&[long_user_message("hi")]) > estimate_tokens("hi")); +} + +#[test] +fn below_threshold_returns_none() { + let messages: Vec = (0..5) + .map(|i| long_user_message(&format!("msg {}", i))) + .collect(); + // 窗口极大 → 不触发。 + // Huge window → no trigger. + assert!(plan_compaction( + &messages, + 1_000_000, + DEFAULT_THRESHOLD_RATIO, + DEFAULT_KEEP_RATIO + ) + .is_none()); +} + +#[test] +fn above_threshold_plans_summarize_and_keep() { + // 每条消息约 50+ token("x"*200 → 50 token + 4 开销);10 条约 540 token。 + // Each message ≈ 50+ tokens; 10 of them ≈ 540 tokens. + let messages: Vec = (0..10) + .map(|_i| long_user_message(&"x".repeat(200))) + .collect(); + let plan = + plan_compaction(&messages, 200, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan"); + assert!(!plan.summarize.is_empty()); + assert!(!plan.keep.is_empty()); + assert!(plan.summarize.len() + plan.keep.len() == messages.len()); + // 摘要指令包含两个部分。 + // The summary instruction has both sections. + assert!(plan.summary_instruction.contains("")); + assert!(plan.summary_instruction.contains("")); + // 保存的 token 为正。 + // Saved tokens are positive. + assert!(plan.estimated_saved_tokens > 0); +} + +#[test] +fn keeps_tool_result_pairs_intact() { + // 构造:user → assistant(tool_call) → toolResult → assistant(text), + // 其中 tool 部分恰好落在边界附近。 + // Build: user → assistant(tool_call) → toolResult → assistant(text), + // with the tool part landing near the boundary. + let mut args = JsonValue::obj(); + args.insert("x", "1".into()).ok(); + let mut messages = vec![long_user_message(&"a".repeat(300))]; + messages.push(Message::Assistant(AssistantMessage { + content: vec![ContentBlock::ToolCall(ToolCall { + id: "c1".into(), + name: "read".into(), + arguments: args, + })], + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::ToolUse, + error_message: None, + timestamp: 0, + })); + messages.push(Message::ToolResult(ToolResultMessage { + tool_call_id: "c1".into(), + tool_name: "read".into(), + content: vec![ContentBlock::text("r".repeat(300))], + details: JsonValue::obj(), + is_error: false, + timestamp: 0, + })); + messages.push(Message::Assistant(AssistantMessage { + content: vec![ContentBlock::text("final")], + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 0, + })); + + let plan = + plan_compaction(&messages, 100, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan"); + // 若 tool_result 在 keep 中,其 assistant 调用消息必须也在 keep 中。 + // If the tool_result is kept, its assistant call message must be kept too. + let keep: Vec<&Message> = plan.keep.iter().collect(); + if keep.iter().any(|m| matches!(m, Message::ToolResult(_))) { + assert!(keep.iter().any(|m| { + matches!(m, Message::Assistant(a) + if a.content.iter().any(|c| c.as_tool_call().is_some())) + })); + } +} + +#[test] +fn apply_summary_prepends_summary_message() { + let messages: Vec = (0..6) + .map(|_i| long_user_message(&"x".repeat(200))) + .collect(); + let plan = plan_compaction(&messages, 100, 0.8, 0.6).expect("plan"); + let out = apply_summary("SUMMARY TEXT", &plan); + assert_eq!(out.len(), plan.keep.len() + 1); + match &out[0] { + Message::User(u) => { + let text = u.content[0].as_text().unwrap().text.clone(); + assert!(text.contains("SUMMARY TEXT")); + } + other => panic!("expected user summary, got {:?}", other), + } + // 保留部分原样在后。 + // The kept part follows verbatim. + assert_eq!(out[1..], plan.keep[..]); +} diff --git a/crates/focus-harness/tests/prompt_tests.rs b/crates/focus-harness/tests/prompt_tests.rs new file mode 100644 index 0000000..32d2194 --- /dev/null +++ b/crates/focus-harness/tests/prompt_tests.rs @@ -0,0 +1,83 @@ +//! 系统提示模板的单元测试。 +//! Unit tests for system prompt templates. + +use focus_core::model::Usage; +use focus_harness::prompt::{ + days_to_ymd, default_context, ContextUsage, PromptContext, SystemPromptTemplate, +}; +use std::path::PathBuf; + +#[test] +fn days_to_ymd_known_dates() { + // 1970-01-01 → 0。 + assert_eq!(days_to_ymd(0), (1970, 1, 1)); + // 2000-01-01。 + // 30 年 * 365.25 ≈ 10957 天。 + assert_eq!(days_to_ymd(10_957), (2000, 1, 1)); + // 2024-02-29(闰年)。 + // 2024-02-29 (leap year). + let days = (2024 - 1970) * 365 + (2024 - 1969) / 4 + 59; + assert_eq!(days_to_ymd(days), (2024, 2, 29)); +} + +#[test] +fn renders_default_template() { + let tpl = SystemPromptTemplate::default(); + let mut ctx = default_context(); + ctx.context_usage = Some(ContextUsage { + estimated_tokens: 12_345, + context_window: 128_000, + window_known: true, + }); + ctx.last_usage = Some(Usage { + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 5, + cache_write_tokens: 0, + }); + ctx.extra.push("remember: rust edition 2021".to_string()); + let out = tpl.render(&ctx); + assert!(out.contains(&ctx.cwd.display().to_string())); + assert!( + out.contains("Context usage: 12,345 / 128,000 tokens (9.6%)."), + "got: {}", + out + ); + assert!(out.contains("Last turn usage: input 100, output 50, cache-read 5, cache-write 0.")); + assert!(out.contains("remember: rust edition 2021")); + assert!(out.contains(ctx.os)); +} + +#[test] +fn renders_unknown_window_note() { + let tpl = SystemPromptTemplate::default(); + let mut ctx = default_context(); + ctx.context_usage = Some(ContextUsage { + estimated_tokens: 50_000, + context_window: 128_000, + window_known: false, + }); + let out = tpl.render(&ctx); + assert!( + out.contains("window unknown; assuming 128,000"), + "got: {}", + out + ); +} + +#[test] +fn missing_fields_render_empty() { + let tpl = SystemPromptTemplate::default(); + let ctx = PromptContext { + cwd: PathBuf::from("/tmp"), + os: "linux", + date: "2025-01-01".into(), + shell: "bash".into(), + context_usage: None, + last_usage: None, + extra: Vec::new(), + }; + let out = tpl.render(&ctx); + assert!(!out.contains("{context_usage}")); + assert!(!out.contains("{last_usage}")); +} diff --git a/crates/focus-harness/tests/session_tests.rs b/crates/focus-harness/tests/session_tests.rs new file mode 100644 index 0000000..e307292 --- /dev/null +++ b/crates/focus-harness/tests/session_tests.rs @@ -0,0 +1,124 @@ +//! 会话树与 JSONL 持久化的单元测试。 +//! Unit tests for session trees and JSONL persistence. + +use focus_core::model::Message; +use focus_harness::session::{SessionEntry, SessionStore, SessionTree}; +use std::path::PathBuf; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "focus-harness-session-{}-{}", + tag, + std::process::id() + )); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn appends_and_loads_entries() { + let dir = temp_dir("append"); + let store = SessionStore::new(&dir); + store + .append( + "s1", + &SessionEntry::new("e1".into(), None, Message::user_text("hi")), + ) + .unwrap(); + store + .append( + "s1", + &SessionEntry::new("e2".into(), Some("e1".into()), Message::user_text("there")), + ) + .unwrap(); + + let entries = store.load("s1").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, "e1"); + assert_eq!(entries[0].parent_id, None); + assert_eq!(entries[1].id, "e2"); + assert_eq!(entries[1].parent_id.as_deref(), Some("e1")); + assert!(matches!(entries[0].message, Message::User(_))); +} + +#[test] +fn next_entry_id_is_incremental() { + let dir = temp_dir("seq"); + let store = SessionStore::new(&dir); + assert_eq!(store.next_entry_id("s1").unwrap(), "e1"); + store + .append( + "s1", + &SessionEntry::new("e1".into(), None, Message::user_text("a")), + ) + .unwrap(); + assert_eq!(store.next_entry_id("s1").unwrap(), "e2"); + store + .append( + "s1", + &SessionEntry::new("e3".into(), Some("e1".into()), Message::user_text("b")), + ) + .unwrap(); + // e3 已存在 → 下一个是 e4。 + // e3 exists → the next is e4. + assert_eq!(store.next_entry_id("s1").unwrap(), "e4"); +} + +#[test] +fn lists_sessions() { + let dir = temp_dir("list"); + let store = SessionStore::new(&dir); + store + .append( + "alpha", + &SessionEntry::new("e1".into(), None, Message::user_text("a")), + ) + .unwrap(); + store + .append( + "beta", + &SessionEntry::new("e1".into(), None, Message::user_text("b")), + ) + .unwrap(); + let ids = store.list_sessions().unwrap(); + assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]); +} + +fn tree() -> SessionTree { + let entries = vec![ + SessionEntry::new("e1".into(), None, Message::user_text("root")), + SessionEntry::new( + "e2".into(), + Some("e1".into()), + Message::user_text("child A"), + ), + SessionEntry::new( + "e3".into(), + Some("e1".into()), + Message::user_text("child B"), + ), + SessionEntry::new( + "e4".into(), + Some("e3".into()), + Message::user_text("grandchild"), + ), + ]; + SessionTree::build(entries) +} + +#[test] +fn tree_queries() { + let t = tree(); + assert_eq!(t.roots().len(), 1); + assert_eq!(t.roots()[0].id, "e1"); + let children = t.children_of("e1"); + assert_eq!(children.len(), 2); + let path = t.branch("e4"); + let ids: Vec<&str> = path.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, vec!["e1", "e3", "e4"]); + let leaves = t.leaf_ids(); + assert_eq!(leaves.len(), 2); + assert!(leaves.contains(&"e2")); + assert!(leaves.contains(&"e4")); +} diff --git a/crates/focus-providers/src/anthropic.rs b/crates/focus-providers/src/anthropic.rs index 2112857..f081657 100644 --- a/crates/focus-providers/src/anthropic.rs +++ b/crates/focus-providers/src/anthropic.rs @@ -556,119 +556,3 @@ impl AnthropicTurn { } } } - -#[cfg(test)] -mod tests { - use super::*; - use focus_core::tool::{ToolEffects, ToolRegistry}; - - /// 无副作用 echo 工具(供请求体测试)。 - /// Side-effect-free echo tool (for request-body tests). - #[derive(Debug)] - struct EchoTool; - impl focus_core::Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - fn description(&self) -> &str { - "echoes text" - } - fn parameters(&self) -> JsonValue { - let mut o = JsonValue::obj(); - o.insert("type", "object".into()).ok(); - let mut props = JsonValue::obj(); - props.insert("text", JsonValue::Str("the text".into())).ok(); - o.insert("properties", props).ok(); - o - } - fn effects(&self) -> ToolEffects { - ToolEffects::READ - } - fn execute( - &self, - _id: &str, - args: &JsonValue, - _u: Option<&focus_core::tool::ToolUpdateSink>, - ) -> Result { - Ok(focus_core::tool::ToolResult::text( - args.get_str("text").unwrap_or("").to_string(), - )) - } - } - - #[test] - fn serializes_request_body() { - let provider = AnthropicProvider::new(ProviderConfig::new("sk-test")); - let request = ProviderRequest { - model: "claude-sonnet-4".into(), - system_prompt: "You are helpful.".into(), - messages: vec![ - Message::user_text("hi"), - Message::Assistant(AssistantMessage { - content: vec![ - ContentBlock::Thinking(ThinkingContent { - thinking: "hmm".into(), - signature: Some("sig".into()), - redacted: false, - }), - ContentBlock::ToolCall(ToolCall { - id: "call_1".into(), - name: "echo".into(), - arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), - }), - ], - model: "claude-sonnet-4".into(), - usage: Usage::default(), - stop_reason: StopReason::ToolUse, - error_message: None, - timestamp: 0, - }), - Message::ToolResult(ToolResultMessage { - tool_call_id: "call_1".into(), - tool_name: "echo".into(), - content: vec![ContentBlock::text("ok")], - details: JsonValue::obj(), - is_error: false, - timestamp: 0, - }), - ], - tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), - max_tokens: Some(1024), - temperature: Some(0.5), - }; - let body = provider.build_body(&request).unwrap(); - let json: JsonValue = focus_json::parse(&body).unwrap(); - assert_eq!(json.get_str("model"), Some("claude-sonnet-4")); - assert_eq!(json.get_str("system"), Some("You are helpful.")); - assert_eq!(json.get_bool("stream"), Some(true)); - // 工具被翻译成 input_schema。 - // Tools are translated with input_schema. - let tools = json.get_arr("tools").unwrap(); - assert_eq!(tools[0].get_str("name"), Some("echo")); - assert!(tools[0].get("input_schema").is_some()); - // 思考块带签名回传。 - // Thinking blocks are replayed with their signature. - let messages = json.get_arr("messages").unwrap(); - let assistant = &messages[1]; - let content = assistant.get_arr("content").unwrap(); - assert_eq!(content[0].get_str("type"), Some("thinking")); - assert_eq!(content[0].get_str("signature"), Some("sig")); - assert_eq!(content[1].get_str("type"), Some("tool_use")); - assert_eq!(content[1].get_str("id"), Some("call_1")); - // tool_result 落在一条 user 消息里。 - // tool_result lands inside one user message. - let tool_msg = &messages[2]; - assert_eq!(tool_msg.get_str("role"), Some("user")); - let blocks = tool_msg.get_arr("content").unwrap(); - assert_eq!(blocks[0].get_str("type"), Some("tool_result")); - assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1")); - } - - #[test] - fn maps_stop_reasons() { - assert_eq!(map_stop_reason("end_turn"), StopReason::Stop); - assert_eq!(map_stop_reason("max_tokens"), StopReason::Length); - assert_eq!(map_stop_reason("tool_use"), StopReason::ToolUse); - assert_eq!(map_stop_reason("pause_turn"), StopReason::Stop); - } -} diff --git a/crates/focus-providers/src/config.rs b/crates/focus-providers/src/config.rs index cb2f74d..5f46b99 100644 --- a/crates/focus-providers/src/config.rs +++ b/crates/focus-providers/src/config.rs @@ -123,49 +123,3 @@ pub fn split_base_url(base: &str) -> Result<(String, u16, String), String> { } Ok((host, port, path.trim_end_matches('/').to_string())) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_windows_prefix_matching() { - assert_eq!( - known_context_window("claude-sonnet-4-20250514"), - Some(200_000) - ); - assert_eq!(known_context_window("gpt-4o"), Some(128_000)); - assert_eq!(known_context_window("gpt-4.1-mini"), Some(1_000_000)); - assert_eq!(known_context_window("unknown-model"), None); - } - - #[test] - fn resolve_fallback_order() { - // 配置优先。 - // Configured value wins. - assert_eq!(resolve_context_window(Some(42), "claude-sonnet-4"), 42); - // 已知表兜底。 - // Known table as fallback. - assert_eq!(resolve_context_window(None, "claude-sonnet-4"), 200_000); - // 保守默认。 - // Conservative default. - assert_eq!( - resolve_context_window(None, "totally-unknown"), - DEFAULT_CONTEXT_WINDOW - ); - } - - #[test] - fn splits_base_urls() { - assert_eq!( - split_base_url("https://api.anthropic.com").unwrap(), - ("api.anthropic.com".to_string(), 443, String::new()) - ); - assert_eq!( - split_base_url("https://localhost:8080/v1").unwrap(), - ("localhost".to_string(), 8080, "/v1".to_string()) - ); - assert!(split_base_url("http://insecure.example.com").is_err()); - assert!(split_base_url("https:///no-host").is_err()); - } -} diff --git a/crates/focus-providers/src/openai.rs b/crates/focus-providers/src/openai.rs index 418176b..103618d 100644 --- a/crates/focus-providers/src/openai.rs +++ b/crates/focus-providers/src/openai.rs @@ -735,129 +735,3 @@ impl OpenAiTurn { } } } - -#[cfg(test)] -mod tests { - use super::*; - use focus_core::tool::{ToolEffects, ToolRegistry}; - - #[derive(Debug)] - struct EchoTool; - impl focus_core::Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - fn description(&self) -> &str { - "echoes text" - } - fn parameters(&self) -> JsonValue { - let mut o = JsonValue::obj(); - o.insert("type", "object".into()).ok(); - let mut props = JsonValue::obj(); - props.insert("text", JsonValue::Str("the text".into())).ok(); - o.insert("properties", props).ok(); - o - } - fn effects(&self) -> ToolEffects { - ToolEffects::READ - } - fn execute( - &self, - _id: &str, - args: &JsonValue, - _u: Option<&focus_core::tool::ToolUpdateSink>, - ) -> Result { - Ok(focus_core::tool::ToolResult::text( - args.get_str("text").unwrap_or("").to_string(), - )) - } - } - - fn sample_request() -> ProviderRequest { - ProviderRequest { - model: "gpt-4o".into(), - system_prompt: "Be concise.".into(), - messages: vec![ - Message::user_text("hi"), - Message::Assistant(AssistantMessage { - content: vec![ContentBlock::ToolCall(ToolCall { - id: "call_abc".into(), - name: "echo".into(), - arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), - })], - model: "gpt-4o".into(), - usage: Usage::default(), - stop_reason: StopReason::ToolUse, - error_message: None, - timestamp: 0, - }), - Message::ToolResult(ToolResultMessage { - tool_call_id: "call_abc".into(), - tool_name: "echo".into(), - content: vec![ContentBlock::text("ok")], - details: JsonValue::obj(), - is_error: false, - timestamp: 0, - }), - ], - tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), - max_tokens: Some(512), - temperature: Some(0.2), - } - } - - #[test] - fn chat_body_translation() { - let body = build_chat_body(&sample_request()); - let messages = body.get_arr("messages").unwrap(); - assert_eq!(messages[0].get_str("role"), Some("system")); - assert_eq!(messages[0].get_str("content"), Some("Be concise.")); - // assistant 携带 tool_calls(arguments 为 JSON 字符串)。 - // Assistant carries tool_calls (arguments as a JSON string). - let assistant = &messages[2]; - let calls = assistant.get_arr("tool_calls").unwrap(); - assert_eq!(calls[0].get_str("id"), Some("call_abc")); - let func = calls[0].get("function").unwrap(); - assert_eq!(func.get_str("name"), Some("echo")); - assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#)); - // tool 消息带 tool_call_id。 - // Tool message carries tool_call_id. - assert_eq!(messages[3].get_str("role"), Some("tool")); - assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc")); - // tools 被翻译成 function 格式。 - // Tools translated into the function format. - let tools = body.get_arr("tools").unwrap(); - assert_eq!(tools[0].get_str("type"), Some("function")); - let f = tools[0].get("function").unwrap(); - assert_eq!(f.get_str("name"), Some("echo")); - assert!(f.get("parameters").is_some()); - assert_eq!(body.get_bool("stream"), Some(true)); - assert_eq!(body.get_num("max_completion_tokens"), Some(512.0)); - } - - #[test] - fn responses_body_translation() { - let body = build_responses_body(&sample_request()); - assert_eq!(body.get_str("instructions"), Some("Be concise.")); - let input = body.get_arr("input").unwrap(); - // 顺序:user → assistant(role) → function_call → function_call_output。 - // Order: user → assistant(role) → function_call → function_call_output. - assert_eq!(input[0].get_str("role"), Some("user")); - assert_eq!(input[1].get_str("role"), Some("assistant")); - assert_eq!(input[2].get_str("type"), Some("function_call")); - assert_eq!(input[2].get_str("call_id"), Some("call_abc")); - assert_eq!(input[3].get_str("type"), Some("function_call_output")); - assert_eq!(input[3].get_str("call_id"), Some("call_abc")); - let tools = body.get_arr("tools").unwrap(); - assert_eq!(tools[0].get_str("name"), Some("echo")); - assert_eq!(body.get_num("max_output_tokens"), Some(512.0)); - } - - #[test] - fn maps_chat_finish_reasons() { - assert_eq!(map_chat_finish("stop"), StopReason::Stop); - assert_eq!(map_chat_finish("length"), StopReason::Length); - assert_eq!(map_chat_finish("tool_calls"), StopReason::ToolUse); - assert_eq!(map_chat_finish("content_filter"), StopReason::Stop); - } -} diff --git a/crates/focus-providers/tests/anthropic_tests.rs b/crates/focus-providers/tests/anthropic_tests.rs index 26c1061..bc735ea 100644 --- a/crates/focus-providers/tests/anthropic_tests.rs +++ b/crates/focus-providers/tests/anthropic_tests.rs @@ -5,6 +5,7 @@ mod common; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; +use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_providers::anthropic::AnthropicProvider; use focus_providers::config::ProviderConfig; use focus_transport::TransportError; @@ -234,4 +235,142 @@ fn request_is_well_formed() { focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); assert_eq!(body.get_str("model"), Some("claude-sonnet-4")); assert_eq!(body.get_bool("stream"), Some(true)); + assert_eq!(body.get_str("system"), Some("You are helpful.")); +} + +/// 请求体必须完整携带会话:思考块签名回传、tool_use 块、多个 tool_result +/// 合并进一条 user 消息、工具翻译成 input_schema(回归:原为内联私有函数测试)。 +/// The request body must carry the full transcript: thinking signatures, +/// tool_use blocks, multiple tool_results merged into one user message, and +/// tools translated to input_schema (regression: was an inline private-fn test). +#[test] +fn request_body_carries_full_transcript() { + #[derive(Debug)] + struct EchoTool; + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echoes text" + } + fn parameters(&self) -> focus_json::JsonValue { + let mut o = focus_json::JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = focus_json::JsonValue::obj(); + props + .insert("text", focus_json::JsonValue::Str("the text".into())) + .ok(); + o.insert("properties", props).ok(); + o + } + fn effects(&self) -> ToolEffects { + ToolEffects::READ + } + fn execute( + &self, + _id: &str, + _args: &focus_json::JsonValue, + _u: Option<&ToolUpdateSink>, + ) -> Result { + Ok(ToolResult::text("ok")) + } + } + + let rich_request = ProviderRequest { + model: "claude-sonnet-4".into(), + system_prompt: "You are helpful.".into(), + messages: vec![ + Message::user_text("hi"), + Message::Assistant(AssistantMessage { + content: vec![ + ContentBlock::Thinking(ThinkingContent { + thinking: "hmm".into(), + signature: Some("sig".into()), + redacted: false, + }), + ContentBlock::ToolCall(ToolCall { + id: "call_1".into(), + name: "echo".into(), + arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), + }), + ], + model: "claude-sonnet-4".into(), + usage: Usage::default(), + stop_reason: StopReason::ToolUse, + error_message: None, + timestamp: 0, + }), + Message::ToolResult(ToolResultMessage { + tool_call_id: "call_1".into(), + tool_name: "echo".into(), + content: vec![ContentBlock::text("ok")], + details: focus_json::JsonValue::obj(), + is_error: false, + timestamp: 0, + }), + ], + tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), + max_tokens: Some(1024), + temperature: Some(0.5), + }; + + let mut mock = common::MockTransport::new(); + mock.push_body("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"); + let provider = provider(mock.clone()); + let _ = common::collect(&provider, &rich_request); + + let req = mock.last_request(); + let body: focus_json::JsonValue = + focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); + + // 工具翻译成 input_schema。 + // Tools translated with input_schema. + let tools = body.get_arr("tools").unwrap(); + assert_eq!(tools[0].get_str("name"), Some("echo")); + assert!(tools[0].get("input_schema").is_some()); + + // 思考块带签名回传。 + // Thinking blocks replayed with their signature. + let messages = body.get_arr("messages").unwrap(); + let assistant = &messages[1]; + let content = assistant.get_arr("content").unwrap(); + assert_eq!(content[0].get_str("type"), Some("thinking")); + assert_eq!(content[0].get_str("signature"), Some("sig")); + assert_eq!(content[1].get_str("type"), Some("tool_use")); + assert_eq!(content[1].get_str("id"), Some("call_1")); + + // tool_result 落在一条 user 消息里。 + // tool_result lands inside one user message. + let tool_msg = &messages[2]; + assert_eq!(tool_msg.get_str("role"), Some("user")); + let blocks = tool_msg.get_arr("content").unwrap(); + assert_eq!(blocks[0].get_str("type"), Some("tool_result")); + assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1")); +} + +/// stop_reason 映射:max_tokens → Length(回归:原为内联私有函数测试)。 +/// Stop-reason mapping: max_tokens → Length (regression: was an inline +/// private-fn test). +#[test] +fn maps_max_tokens_stop_reason() { + let mut mock = common::MockTransport::new(); + mock.push_body( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\n\n\ + event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ + event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n\ + event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ + event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"},\"usage\":{}}\n\n\ + event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ); + let provider = provider(mock); + let events = common::collect(&provider, &request()); + let done = events + .iter() + .find_map(|e| match e { + StreamEvent::Done { message } => Some(message), + _ => None, + }) + .expect("done event"); + assert_eq!(done.stop_reason, StopReason::Length); } diff --git a/crates/focus-providers/tests/config_tests.rs b/crates/focus-providers/tests/config_tests.rs new file mode 100644 index 0000000..ca22d9b --- /dev/null +++ b/crates/focus-providers/tests/config_tests.rs @@ -0,0 +1,56 @@ +//! provider 配置(已知模型窗口表、base_url 拆分)的单元测试。 +//! Unit tests for provider config (known-model windows, base_url splitting). + +use focus_providers::config::{ + known_context_window, resolve_context_window, split_base_url, ProviderConfig, + DEFAULT_CONTEXT_WINDOW, +}; + +#[test] +fn known_windows_prefix_matching() { + assert_eq!( + known_context_window("claude-sonnet-4-20250514"), + Some(200_000) + ); + assert_eq!(known_context_window("gpt-4o"), Some(128_000)); + assert_eq!(known_context_window("gpt-4.1-mini"), Some(1_000_000)); + assert_eq!(known_context_window("unknown-model"), None); +} + +#[test] +fn resolve_fallback_order() { + // 配置优先。 + // Configured value wins. + assert_eq!(resolve_context_window(Some(42), "claude-sonnet-4"), 42); + // 已知表兜底。 + // Known table as fallback. + assert_eq!(resolve_context_window(None, "claude-sonnet-4"), 200_000); + // 保守默认。 + // Conservative default. + assert_eq!( + resolve_context_window(None, "totally-unknown"), + DEFAULT_CONTEXT_WINDOW + ); +} + +#[test] +fn splits_base_urls() { + assert_eq!( + split_base_url("https://api.anthropic.com").unwrap(), + ("api.anthropic.com".to_string(), 443, String::new()) + ); + assert_eq!( + split_base_url("https://localhost:8080/v1").unwrap(), + ("localhost".to_string(), 8080, "/v1".to_string()) + ); + assert!(split_base_url("http://insecure.example.com").is_err()); + assert!(split_base_url("https:///no-host").is_err()); +} + +#[test] +fn config_defaults() { + let c = ProviderConfig::new("sk-test"); + assert_eq!(c.api_key, "sk-test"); + assert_eq!(c.base_url, None); + assert_eq!(c.context_window, None); +} diff --git a/crates/focus-providers/tests/openai_tests.rs b/crates/focus-providers/tests/openai_tests.rs index 2acb6de..89dea4e 100644 --- a/crates/focus-providers/tests/openai_tests.rs +++ b/crates/focus-providers/tests/openai_tests.rs @@ -6,6 +6,7 @@ mod common; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; +use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_providers::config::ProviderConfig; use focus_providers::openai::{OpenAiProtocol, OpenAiProvider}; use std::sync::Arc; @@ -191,3 +192,157 @@ fn request_headers_and_path() { .iter() .any(|(k, v)| { k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test" })); } + +/// Chat Completions 请求体翻译(回归:原为内联私有函数测试)。 +/// Chat Completions request-body translation (regression: was an inline +/// private-fn test). +#[test] +fn chat_request_body_translation() { + let mut mock = common::MockTransport::new(); + mock.push_body("data: [DONE]\n\n"); + let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); + let _ = common::collect(&provider, &rich_request()); + + let req = mock.last_request(); + let body: focus_json::JsonValue = + focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); + let messages = body.get_arr("messages").unwrap(); + // system 提示作为第一条消息。 + // The system prompt becomes the first message. + assert_eq!(messages[0].get_str("role"), Some("system")); + assert_eq!(messages[0].get_str("content"), Some("Be concise.")); + // assistant 携带 tool_calls(arguments 为 JSON 字符串)。 + // Assistant carries tool_calls (arguments as a JSON string). + let assistant = &messages[2]; + let calls = assistant.get_arr("tool_calls").unwrap(); + assert_eq!(calls[0].get_str("id"), Some("call_abc")); + let func = calls[0].get("function").unwrap(); + assert_eq!(func.get_str("name"), Some("echo")); + assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#)); + // tool 消息带 tool_call_id。 + // Tool message carries tool_call_id. + assert_eq!(messages[3].get_str("role"), Some("tool")); + assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc")); + // tools 被翻译成 function 格式。 + // Tools translated into the function format. + let tools = body.get_arr("tools").unwrap(); + assert_eq!(tools[0].get_str("type"), Some("function")); + let f = tools[0].get("function").unwrap(); + assert_eq!(f.get_str("name"), Some("echo")); + assert!(f.get("parameters").is_some()); + assert_eq!(body.get_bool("stream"), Some(true)); + assert_eq!(body.get_num("max_completion_tokens"), Some(512.0)); +} + +/// Responses API 请求体翻译(回归:原为内联私有函数测试)。 +/// Responses API request-body translation (regression: was an inline +/// private-fn test). +#[test] +fn responses_request_body_translation() { + let mut mock = common::MockTransport::new(); + mock.push_body("data: [DONE]\n\n"); + let provider = provider(mock.clone(), OpenAiProtocol::Responses); + let _ = common::collect(&provider, &rich_request()); + + let req = mock.last_request(); + assert_eq!(req.path, "/v1/responses"); + let body: focus_json::JsonValue = + focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); + assert_eq!(body.get_str("instructions"), Some("Be concise.")); + let input = body.get_arr("input").unwrap(); + // 顺序:user → assistant(role) → function_call → function_call_output。 + // Order: user → assistant(role) → function_call → function_call_output. + assert_eq!(input[0].get_str("role"), Some("user")); + assert_eq!(input[1].get_str("role"), Some("assistant")); + assert_eq!(input[2].get_str("type"), Some("function_call")); + assert_eq!(input[2].get_str("call_id"), Some("call_abc")); + assert_eq!(input[3].get_str("type"), Some("function_call_output")); + assert_eq!(input[3].get_str("call_id"), Some("call_abc")); + let tools = body.get_arr("tools").unwrap(); + assert_eq!(tools[0].get_str("name"), Some("echo")); + assert_eq!(body.get_num("max_output_tokens"), Some(512.0)); +} + +/// finish_reason 映射:length → Length(回归:原为内联私有函数测试)。 +/// Finish-reason mapping: length → Length (regression: was an inline +/// private-fn test). +#[test] +fn maps_chat_length_finish_reason() { + let mut mock = common::MockTransport::new(); + mock.push_body( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"length\"}]}\n\n\ + data: [DONE]\n\n", + ); + let provider = provider(mock, OpenAiProtocol::ChatCompletions); + let events = common::collect(&provider, &request()); + let msg = final_message(&events); + assert_eq!(msg.stop_reason, StopReason::Length); +} + +/// 一个携带工具调用与工具结果的更完整请求。 +/// A richer request carrying a tool call and its result. +fn rich_request() -> ProviderRequest { + #[derive(Debug)] + struct EchoTool; + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echoes text" + } + fn parameters(&self) -> focus_json::JsonValue { + let mut o = focus_json::JsonValue::obj(); + o.insert("type", "object".into()).ok(); + let mut props = focus_json::JsonValue::obj(); + props + .insert("text", focus_json::JsonValue::Str("the text".into())) + .ok(); + o.insert("properties", props).ok(); + o + } + fn effects(&self) -> ToolEffects { + ToolEffects::READ + } + fn execute( + &self, + _id: &str, + _args: &focus_json::JsonValue, + _u: Option<&ToolUpdateSink>, + ) -> Result { + Ok(ToolResult::text("ok")) + } + } + + ProviderRequest { + model: "gpt-4o".into(), + system_prompt: "Be concise.".into(), + messages: vec![ + Message::user_text("hi"), + Message::Assistant(AssistantMessage { + content: vec![ContentBlock::ToolCall(ToolCall { + id: "call_abc".into(), + name: "echo".into(), + arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), + })], + model: "gpt-4o".into(), + usage: Usage::default(), + stop_reason: StopReason::ToolUse, + error_message: None, + timestamp: 0, + }), + Message::ToolResult(ToolResultMessage { + tool_call_id: "call_abc".into(), + tool_name: "echo".into(), + content: vec![ContentBlock::text("ok")], + details: focus_json::JsonValue::obj(), + is_error: false, + timestamp: 0, + }), + ], + tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), + max_tokens: Some(512), + temperature: Some(0.2), + } +} diff --git a/crates/focus-tools/src/edit.rs b/crates/focus-tools/src/edit.rs index 0f6fcc2..e86ca8f 100644 --- a/crates/focus-tools/src/edit.rs +++ b/crates/focus-tools/src/edit.rs @@ -176,108 +176,3 @@ impl Tool for EditTool { }) } } - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn temp_dir(tag: &str) -> PathBuf { - let d = - std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - fn edit_args(path: &str, edits: Vec) -> JsonValue { - let mut args = JsonValue::obj(); - args.insert("path", path.into()).ok(); - args.insert("edits", JsonValue::Arr(edits)).ok(); - args - } - - fn pair(old: &str, new: &str) -> JsonValue { - let mut e = JsonValue::obj(); - e.insert("oldString", old.into()).ok(); - e.insert("newString", new.into()).ok(); - e - } - - #[test] - fn applies_single_replacement() { - let dir = temp_dir("single"); - fs::write(dir.join("f.txt"), "foo bar foo").unwrap(); - let tool = EditTool::new(&dir); - let r = tool - .execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None) - .unwrap(); - assert!(r.content[0].as_text().unwrap().text.contains("1 edit")); - assert_eq!( - fs::read_to_string(dir.join("f.txt")).unwrap(), - "foo baz foo" - ); - } - - #[test] - fn applies_multiple_edits_in_order() { - let dir = temp_dir("multi"); - fs::write(dir.join("f.txt"), "a b c").unwrap(); - let tool = EditTool::new(&dir); - let r = tool - .execute( - "id", - &edit_args( - "f.txt", - vec![pair("a", "1"), pair("b", "2"), pair("c", "3")], - ), - None, - ) - .unwrap(); - assert!(r.content[0].as_text().unwrap().text.contains("3 edit")); - assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3"); - } - - #[test] - fn ambiguous_match_requires_occurrence() { - let dir = temp_dir("ambig"); - fs::write(dir.join("f.txt"), "x x x").unwrap(); - let tool = EditTool::new(&dir); - // 无 occurrence → 报错。 - // No occurrence → error. - let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None); - assert!(err.is_err()); - let msg = err.unwrap_err().to_string(); - assert!(msg.contains("3 times"), "got: {}", msg); - // 带 occurrence → 只替换第 2 个。 - // With occurrence → replace only the 2nd. - let mut e = pair("x", "y"); - e.insert("occurrence", 2u64.into()).ok(); - tool.execute("id", &edit_args("f.txt", vec![e]), None) - .unwrap(); - assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x"); - } - - #[test] - fn not_found_reports_descriptively() { - let dir = temp_dir("nf"); - fs::write(dir.join("f.txt"), "hello").unwrap(); - let tool = EditTool::new(&dir); - let err = tool - .execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None) - .unwrap_err(); - assert!(err.to_string().contains("not found"), "got: {}", err); - } - - #[test] - fn preserves_crlf_when_not_matched() { - let dir = temp_dir("crlf"); - fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap(); - let tool = EditTool::new(&dir); - tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None) - .unwrap(); - // 未匹配的 CRLF 原样保留。 - // Unmatched CRLF bytes are preserved as-is. - assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n"); - } -} diff --git a/crates/focus-tools/src/read.rs b/crates/focus-tools/src/read.rs index 2c9bc75..76952b5 100644 --- a/crates/focus-tools/src/read.rs +++ b/crates/focus-tools/src/read.rs @@ -122,83 +122,3 @@ fn slice_lines(content: &str, start: Option, end: Option) -> Strin } lines[start_idx..end_idx].join("\n") } - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn temp_dir(tag: &str) -> PathBuf { - let d = - std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn reads_file() { - let dir = temp_dir("reads"); - fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap(); - let tool = ReadTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "a.txt".into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - let text = r.content[0].as_text().unwrap().text.clone(); - assert_eq!(text, "line1\nline2\nline3\n"); - } - - #[test] - fn reads_line_range() { - let dir = temp_dir("range"); - fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap(); - let tool = ReadTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "a.txt".into()).ok(); - args.insert("startLine", 2u64.into()).ok(); - args.insert("endLine", 3u64.into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - let text = r.content[0].as_text().unwrap().text.clone(); - assert_eq!(text, "l2\nl3"); - } - - #[test] - fn missing_file_is_an_error_result() { - let dir = temp_dir("missing"); - let tool = ReadTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "nope.txt".into()).ok(); - // 可预期错误以 Err 返回(agent 循环会将其编码为错误结果)。 - // Expected errors surface as Err (the agent loop encodes them as - // error results). - let err = tool.execute("id", &args, None).unwrap_err().to_string(); - assert!(err.contains("nope.txt"), "got: {}", err); - } - - #[test] - fn binary_file_is_rejected() { - let dir = temp_dir("bin"); - fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap(); - let tool = ReadTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "b.bin".into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - assert!(r.content[0] - .as_text() - .map(|t| t.text.contains("binary")) - .unwrap_or(false)); - } - - #[test] - fn absolute_path_works() { - let dir = temp_dir("abs"); - let abs = dir.join("x.txt"); - fs::write(&abs, "hi").unwrap(); - let tool = ReadTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", abs.display().to_string().into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - let text = r.content[0].as_text().unwrap().text.clone(); - assert_eq!(text, "hi"); - } -} diff --git a/crates/focus-tools/src/shell.rs b/crates/focus-tools/src/shell.rs index 5e1bba2..63d11ed 100644 --- a/crates/focus-tools/src/shell.rs +++ b/crates/focus-tools/src/shell.rs @@ -307,98 +307,3 @@ fn truncate(s: &str, max: usize) -> String { out.push_str(&s.chars().skip(s.len() - half).collect::()); out } - -#[cfg(test)] -mod tests { - use super::*; - use focus_core::tool::ToolUpdate; - use std::fs; - use std::sync::{Arc, Mutex}; - - fn temp_dir(tag: &str) -> PathBuf { - let d = - std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - fn cmd_args(command: &str) -> JsonValue { - let mut args = JsonValue::obj(); - args.insert("command", command.into()).ok(); - args - } - - #[cfg(unix)] - #[test] - fn runs_bash_and_captures_stdout() { - let dir = temp_dir("stdout"); - let tool = ShellTool::new(&dir); - let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap(); - let text = r.content[0].as_text().unwrap().text.clone(); - assert_eq!(text.trim(), "hello"); - assert_eq!(r.details.get_num("exitCode"), Some(0.0)); - assert_eq!(r.details.get_bool("timedOut"), Some(false)); - } - - #[cfg(unix)] - #[test] - fn captures_stderr_and_exit_code() { - let dir = temp_dir("stderr"); - let tool = ShellTool::new(&dir); - let r = tool - .execute("id", &cmd_args("echo oops >&2; exit 3"), None) - .unwrap(); - assert_eq!(r.details.get_num("exitCode"), Some(3.0)); - let stderr = r.details.get_str("stderr").unwrap_or(""); - assert!(stderr.contains("oops"), "got: {}", stderr); - } - - #[cfg(unix)] - #[test] - fn times_out_and_kills() { - let dir = temp_dir("timeout"); - let tool = ShellTool::new(&dir); - let mut args = cmd_args("sleep 5"); - args.insert("timeoutMs", 200u64.into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - assert_eq!(r.details.get_bool("timedOut"), Some(true)); - let text = r.content[0].as_text().unwrap().text.clone(); - assert!(text.contains("timed out"), "got: {}", text); - } - - #[cfg(unix)] - #[test] - fn streams_updates_line_by_line() { - let dir = temp_dir("updates"); - let tool = ShellTool::new(&dir); - let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink: Box = { - let lines = lines.clone(); - Box::new(move |u: ToolUpdate| { - if let Some(t) = u.content[0].as_text() { - lines.lock().unwrap().push(t.text.clone()); - } - }) - }; - let r = tool - .execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink)) - .unwrap(); - assert_eq!(r.details.get_num("exitCode"), Some(0.0)); - let got = lines.lock().unwrap(); - assert_eq!( - *got, - vec!["a".to_string(), "b".to_string(), "c".to_string()] - ); - } - - #[cfg(unix)] - #[test] - fn runs_in_project_root() { - let dir = temp_dir("cwd"); - let tool = ShellTool::new(&dir); - let r = tool.execute("id", &cmd_args("pwd"), None).unwrap(); - let text = r.content[0].as_text().unwrap().text.clone(); - assert_eq!(text.trim(), dir.display().to_string()); - } -} diff --git a/crates/focus-tools/src/write.rs b/crates/focus-tools/src/write.rs index 083a5b0..8f221f9 100644 --- a/crates/focus-tools/src/write.rs +++ b/crates/focus-tools/src/write.rs @@ -98,47 +98,3 @@ impl Tool for WriteTool { }) } } - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn temp_dir(tag: &str) -> PathBuf { - let d = - std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn writes_and_overwrites() { - let dir = temp_dir("overwrite"); - fs::write(dir.join("a.txt"), "old").unwrap(); - let tool = WriteTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "a.txt".into()).ok(); - args.insert("content", "new content".into()).ok(); - let r = tool.execute("id", &args, None).unwrap(); - assert!(r.content[0].as_text().unwrap().text.contains("11 bytes")); - assert_eq!( - fs::read_to_string(dir.join("a.txt")).unwrap(), - "new content" - ); - } - - #[test] - fn creates_parent_directories() { - let dir = temp_dir("nested"); - let tool = WriteTool::new(&dir); - let mut args = JsonValue::obj(); - args.insert("path", "deep/nested/file.txt".into()).ok(); - args.insert("content", "hi".into()).ok(); - tool.execute("id", &args, None).unwrap(); - assert_eq!( - fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(), - "hi" - ); - } -} diff --git a/crates/focus-tools/tests/edit_tests.rs b/crates/focus-tools/tests/edit_tests.rs new file mode 100644 index 0000000..b630c4c --- /dev/null +++ b/crates/focus-tools/tests/edit_tests.rs @@ -0,0 +1,106 @@ +//! `edit` 工具的单元测试。 +//! Unit tests for the `edit` tool. + +use focus_core::tool::Tool; +use focus_json::JsonValue; +use focus_tools::EditTool; +use std::fs; +use std::path::PathBuf; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d +} + +fn edit_args(path: &str, edits: Vec) -> JsonValue { + let mut args = JsonValue::obj(); + args.insert("path", path.into()).ok(); + args.insert("edits", JsonValue::Arr(edits)).ok(); + args +} + +fn pair(old: &str, new: &str) -> JsonValue { + let mut e = JsonValue::obj(); + e.insert("oldString", old.into()).ok(); + e.insert("newString", new.into()).ok(); + e +} + +#[test] +fn applies_single_replacement() { + let dir = temp_dir("single"); + fs::write(dir.join("f.txt"), "foo bar foo").unwrap(); + let tool = EditTool::new(&dir); + let r = tool + .execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None) + .unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("1 edit")); + assert_eq!( + fs::read_to_string(dir.join("f.txt")).unwrap(), + "foo baz foo" + ); +} + +#[test] +fn applies_multiple_edits_in_order() { + let dir = temp_dir("multi"); + fs::write(dir.join("f.txt"), "a b c").unwrap(); + let tool = EditTool::new(&dir); + let r = tool + .execute( + "id", + &edit_args( + "f.txt", + vec![pair("a", "1"), pair("b", "2"), pair("c", "3")], + ), + None, + ) + .unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("3 edit")); + assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3"); +} + +#[test] +fn ambiguous_match_requires_occurrence() { + let dir = temp_dir("ambig"); + fs::write(dir.join("f.txt"), "x x x").unwrap(); + let tool = EditTool::new(&dir); + // 无 occurrence → 报错。 + // No occurrence → error. + let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None); + assert!(err.is_err()); + let msg = err.unwrap_err().to_string(); + assert!(msg.contains("3 times"), "got: {}", msg); + // 带 occurrence → 只替换第 2 个。 + // With occurrence → replace only the 2nd. + let mut e = pair("x", "y"); + e.insert("occurrence", 2u64.into()).ok(); + tool.execute("id", &edit_args("f.txt", vec![e]), None) + .unwrap(); + assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x"); +} + +#[test] +fn not_found_reports_descriptively() { + let dir = temp_dir("nf"); + fs::write(dir.join("f.txt"), "hello").unwrap(); + let tool = EditTool::new(&dir); + let err = tool + .execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None) + .unwrap_err(); + assert!(err.to_string().contains("not found"), "got: {}", err); +} + +#[test] +fn preserves_crlf_when_not_matched() { + let dir = temp_dir("crlf"); + fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap(); + let tool = EditTool::new(&dir); + tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None) + .unwrap(); + // 未匹配的 CRLF 原样保留。 + // Unmatched CRLF bytes are preserved as-is. + assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n"); +} diff --git a/crates/focus-tools/tests/read_tests.rs b/crates/focus-tools/tests/read_tests.rs new file mode 100644 index 0000000..1807983 --- /dev/null +++ b/crates/focus-tools/tests/read_tests.rs @@ -0,0 +1,81 @@ +//! `read` 工具的单元测试。 +//! Unit tests for the `read` tool. + +use focus_core::tool::Tool; +use focus_json::JsonValue; +use focus_tools::ReadTool; +use std::fs; +use std::path::PathBuf; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn reads_file() { + let dir = temp_dir("reads"); + fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "line1\nline2\nline3\n"); +} + +#[test] +fn reads_line_range() { + let dir = temp_dir("range"); + fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + args.insert("startLine", 2u64.into()).ok(); + args.insert("endLine", 3u64.into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "l2\nl3"); +} + +#[test] +fn missing_file_is_an_error_result() { + let dir = temp_dir("missing"); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "nope.txt".into()).ok(); + // 可预期错误以 Err 返回(agent 循环会将其编码为错误结果)。 + // Expected errors surface as Err (the agent loop encodes them as error + // results). + let err = tool.execute("id", &args, None).unwrap_err().to_string(); + assert!(err.contains("nope.txt"), "got: {}", err); +} + +#[test] +fn binary_file_is_rejected() { + let dir = temp_dir("bin"); + fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "b.bin".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert!(r.content[0] + .as_text() + .map(|t| t.text.contains("binary")) + .unwrap_or(false)); +} + +#[test] +fn absolute_path_works() { + let dir = temp_dir("abs"); + let abs = dir.join("x.txt"); + fs::write(&abs, "hi").unwrap(); + let tool = ReadTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", abs.display().to_string().into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text, "hi"); +} diff --git a/crates/focus-tools/tests/shell_tests.rs b/crates/focus-tools/tests/shell_tests.rs new file mode 100644 index 0000000..5b5fe79 --- /dev/null +++ b/crates/focus-tools/tests/shell_tests.rs @@ -0,0 +1,97 @@ +//! `shell` 工具的单元测试(unix 分支;Windows 分支在有 Windows 的 CI 上跑)。 +//! Unit tests for the `shell` tool (unix branch; the windows branch runs on +//! Windows CI). + +use focus_core::tool::Tool; +use focus_core::tool::ToolUpdate; +use focus_json::JsonValue; +use focus_tools::ShellTool; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d +} + +fn cmd_args(command: &str) -> JsonValue { + let mut args = JsonValue::obj(); + args.insert("command", command.into()).ok(); + args +} + +#[cfg(unix)] +#[test] +fn runs_bash_and_captures_stdout() { + let dir = temp_dir("stdout"); + let tool = ShellTool::new(&dir); + let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text.trim(), "hello"); + assert_eq!(r.details.get_num("exitCode"), Some(0.0)); + assert_eq!(r.details.get_bool("timedOut"), Some(false)); +} + +#[cfg(unix)] +#[test] +fn captures_stderr_and_exit_code() { + let dir = temp_dir("stderr"); + let tool = ShellTool::new(&dir); + let r = tool + .execute("id", &cmd_args("echo oops >&2; exit 3"), None) + .unwrap(); + assert_eq!(r.details.get_num("exitCode"), Some(3.0)); + let stderr = r.details.get_str("stderr").unwrap_or(""); + assert!(stderr.contains("oops"), "got: {}", stderr); +} + +#[cfg(unix)] +#[test] +fn times_out_and_kills() { + let dir = temp_dir("timeout"); + let tool = ShellTool::new(&dir); + let mut args = cmd_args("sleep 5"); + args.insert("timeoutMs", 200u64.into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert_eq!(r.details.get_bool("timedOut"), Some(true)); + let text = r.content[0].as_text().unwrap().text.clone(); + assert!(text.contains("timed out"), "got: {}", text); +} + +#[cfg(unix)] +#[test] +fn streams_updates_line_by_line() { + let dir = temp_dir("updates"); + let tool = ShellTool::new(&dir); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink: Box = { + let lines = lines.clone(); + Box::new(move |u: ToolUpdate| { + if let Some(t) = u.content[0].as_text() { + lines.lock().unwrap().push(t.text.clone()); + } + }) + }; + let r = tool + .execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink)) + .unwrap(); + assert_eq!(r.details.get_num("exitCode"), Some(0.0)); + let got = lines.lock().unwrap(); + assert_eq!( + *got, + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); +} + +#[cfg(unix)] +#[test] +fn runs_in_project_root() { + let dir = temp_dir("cwd"); + let tool = ShellTool::new(&dir); + let r = tool.execute("id", &cmd_args("pwd"), None).unwrap(); + let text = r.content[0].as_text().unwrap().text.clone(); + assert_eq!(text.trim(), dir.display().to_string()); +} diff --git a/crates/focus-tools/tests/write_tests.rs b/crates/focus-tools/tests/write_tests.rs new file mode 100644 index 0000000..8e27b5a --- /dev/null +++ b/crates/focus-tools/tests/write_tests.rs @@ -0,0 +1,45 @@ +//! `write` 工具的单元测试。 +//! Unit tests for the `write` tool. + +use focus_core::tool::Tool; +use focus_json::JsonValue; +use focus_tools::WriteTool; +use std::fs; +use std::path::PathBuf; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn writes_and_overwrites() { + let dir = temp_dir("overwrite"); + fs::write(dir.join("a.txt"), "old").unwrap(); + let tool = WriteTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "a.txt".into()).ok(); + args.insert("content", "new content".into()).ok(); + let r = tool.execute("id", &args, None).unwrap(); + assert!(r.content[0].as_text().unwrap().text.contains("11 bytes")); + assert_eq!( + fs::read_to_string(dir.join("a.txt")).unwrap(), + "new content" + ); +} + +#[test] +fn creates_parent_directories() { + let dir = temp_dir("nested"); + let tool = WriteTool::new(&dir); + let mut args = JsonValue::obj(); + args.insert("path", "deep/nested/file.txt".into()).ok(); + args.insert("content", "hi".into()).ok(); + tool.execute("id", &args, None).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(), + "hi" + ); +} diff --git a/crates/focus-transport/src/transport.rs b/crates/focus-transport/src/transport.rs index 8054f7d..1b7514b 100644 --- a/crates/focus-transport/src/transport.rs +++ b/crates/focus-transport/src/transport.rs @@ -422,10 +422,13 @@ impl StreamingBody { /// /// 字节以任意边界喂入(网络分块不保证与 HTTP 分块对齐),解码结果累积在 /// 内部缓冲区中,通过 [`take_decoded`](Self::take_decoded) 取走。 +/// 公开暴露以便独立测试;正常使用经 [`ChunkStream`] 透明完成。 /// Bytes are fed at arbitrary boundaries (network chunks need not align with /// HTTP chunks); decoded bytes accumulate internally and are drained via -/// [`take_decoded`](Self::take_decoded). -pub(crate) struct ChunkedDecoder { +/// [`take_decoded`](Self::take_decoded). Exposed publicly so it can be tested +/// standalone; normal use happens transparently through [`ChunkStream`]. +#[derive(Default)] +pub struct ChunkedDecoder { /// 尚未消费的原始字节。 /// Raw bytes not yet consumed. raw: Vec, @@ -447,27 +450,22 @@ pub(crate) struct ChunkedDecoder { } impl ChunkedDecoder { - fn new() -> Self { - Self { - raw: Vec::new(), - pos: 0, - remaining: None, - expect_crlf: false, - finished: false, - decoded: Vec::new(), - } + /// 创建空的解码器。 + /// Create an empty decoder. + pub fn new() -> Self { + Self::default() } /// 喂入一块原始字节(含分块框架)。 /// Feed a chunk of raw bytes (including chunk framing). - fn feed(&mut self, bytes: &[u8]) { + pub fn feed(&mut self, bytes: &[u8]) { self.raw.extend_from_slice(bytes); self.decode(); } /// 取走当前已解码的全部字节。 /// Drain all currently decoded bytes. - fn take_decoded(&mut self) -> Option> { + pub fn take_decoded(&mut self) -> Option> { if self.decoded.is_empty() { None } else { @@ -477,7 +475,7 @@ impl ChunkedDecoder { /// 是否已到达流末尾(读到大小为 0 的终止分块)。 /// Whether the end of the stream (size-0 terminator) has been reached. - fn is_finished(&self) -> bool { + pub fn is_finished(&self) -> bool { self.finished } @@ -604,77 +602,3 @@ fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { .windows(needle.len()) .position(|window| window == needle) } - -#[cfg(test)] -mod tests { - use super::*; - - /// 把所有解码输出合并为一个字符串(测试辅助)。 - /// Concatenate all decoded output into one string (test helper). - fn drain(dec: &mut ChunkedDecoder) -> String { - let mut out = Vec::new(); - loop { - match dec.take_decoded() { - Some(mut chunk) => out.append(&mut chunk), - None => { - if dec.is_finished() { - break; - } - return String::from_utf8_lossy(&out).to_string(); - } - } - } - String::from_utf8_lossy(&out).to_string() - } - - #[test] - fn decodes_single_chunk_fed_at_once() { - let mut dec = ChunkedDecoder::new(); - dec.feed(b"5\r\nhello\r\n0\r\n\r\n"); - assert_eq!(drain(&mut dec), "hello"); - } - - #[test] - fn decodes_chunk_split_across_feeds() { - // 网络分块与 HTTP 分块不对齐:一次喂一半。 - // Network chunks misalign with HTTP chunks: feed half at a time. - let mut dec = ChunkedDecoder::new(); - dec.feed(b"5\r\nhe"); - assert_eq!(drain(&mut dec), "he"); - dec.feed(b"llo\r\n0\r\n\r\n"); - assert_eq!(drain(&mut dec), "llo"); - } - - #[test] - fn decodes_multiple_chunks_and_extension() { - let mut dec = ChunkedDecoder::new(); - dec.feed(b"5;name=value\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); - assert_eq!(drain(&mut dec), "hello world"); - } - - #[test] - fn yields_partial_data_before_terminator() { - let mut dec = ChunkedDecoder::new(); - // 第一个分块完整、第二个分块只喂了部分,应先把已解码部分吐出。 - // First chunk complete, second partial: yield decoded bytes so far. - dec.feed(b"3\r\nabc\r\n5\r\n12"); - assert_eq!(drain(&mut dec), "abc12"); - dec.feed(b"345\r\n0\r\n\r\n"); - assert_eq!(drain(&mut dec), "345"); - } - - #[test] - fn chunked_streaming_body_end_to_end() { - // 模拟完整 SSE 流(chunked 编码)经 StreamingBody 逐块吐出。 - // Simulate a full SSE stream (chunked-encoded) being drained via StreamingBody. - // 这里只测解码器端到端;网络部分由 provider 的 mock transport 覆盖。 - // Only the decoder is exercised here; the network side is covered by the - // providers' mock transport tests. - let mut dec = ChunkedDecoder::new(); - let payload = "event: message_start\r\ndata: {\"type\":\"x\"}\r\n\r\n"; - assert_eq!(payload.len(), 44); - dec.feed(format!("{:x}\r\n{}", payload.len(), payload).as_bytes()); - dec.feed(b"0\r\n\r\n"); - assert_eq!(drain(&mut dec), payload); - } -} diff --git a/crates/focus-transport/tests/transport_tests.rs b/crates/focus-transport/tests/transport_tests.rs index a5a99b6..bd092db 100644 --- a/crates/focus-transport/tests/transport_tests.rs +++ b/crates/focus-transport/tests/transport_tests.rs @@ -24,3 +24,73 @@ fn decodes_single_chunk() { let body = b"3\r\nabc\r\n0\r\n\r\n"; assert_eq!(decode_chunked(body).unwrap(), b"abc"); } + +// ---- 增量 chunked 解码器 ---- +// ---- incremental chunked decoder ------------------------------------------ + +use focus_transport::transport::ChunkedDecoder; + +/// 把所有解码输出合并为一个字符串(测试辅助)。 +/// Concatenate all decoded output into one string (test helper). +fn drain(dec: &mut ChunkedDecoder) -> String { + let mut out = Vec::new(); + loop { + match dec.take_decoded() { + Some(mut chunk) => out.append(&mut chunk), + None => { + if dec.is_finished() { + break; + } + return String::from_utf8_lossy(&out).to_string(); + } + } + } + String::from_utf8_lossy(&out).to_string() +} + +#[test] +fn decodes_single_chunk_fed_at_once() { + let mut dec = ChunkedDecoder::new(); + dec.feed(b"5\r\nhello\r\n0\r\n\r\n"); + assert_eq!(drain(&mut dec), "hello"); +} + +#[test] +fn decodes_chunk_split_across_feeds() { + // 网络分块与 HTTP 分块不对齐:一次喂一半。 + // Network chunks misalign with HTTP chunks: feed half at a time. + let mut dec = ChunkedDecoder::new(); + dec.feed(b"5\r\nhe"); + assert_eq!(drain(&mut dec), "he"); + dec.feed(b"llo\r\n0\r\n\r\n"); + assert_eq!(drain(&mut dec), "llo"); +} + +#[test] +fn decodes_multiple_chunks_and_extension() { + let mut dec = ChunkedDecoder::new(); + dec.feed(b"5;name=value\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); + assert_eq!(drain(&mut dec), "hello world"); +} + +#[test] +fn yields_partial_data_before_terminator() { + let mut dec = ChunkedDecoder::new(); + // 第一个分块完整、第二个分块只喂了部分,应先把已解码部分吐出。 + // First chunk complete, second partial: yield decoded bytes so far. + dec.feed(b"3\r\nabc\r\n5\r\n12"); + assert_eq!(drain(&mut dec), "abc12"); + dec.feed(b"345\r\n0\r\n\r\n"); + assert_eq!(drain(&mut dec), "345"); +} + +#[test] +fn decodes_an_sse_stream_in_chunked_framing() { + // 模拟完整 SSE 流(chunked 编码)经解码器逐块吐出。 + // Simulate a full SSE stream (chunked-encoded) drained through the decoder. + let payload = "event: message_start\r\ndata: {\"type\":\"x\"}\r\n\r\n"; + let mut dec = ChunkedDecoder::new(); + dec.feed(format!("{:x}\r\n{}\r\n", payload.len(), payload).as_bytes()); + dec.feed(b"0\r\n\r\n"); + assert_eq!(drain(&mut dec), payload); +}