refactor(test): move all unit tests from src/ into tests/<module>_tests.rs
Per AGENTS.md §5.1 every test must live under crates/<name>/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.
This commit is contained in:
parent
7c4c465a18
commit
f695acc845
|
|
@ -225,133 +225,3 @@ pub fn describe_plan(plan: &CompactionPlan) -> String {
|
||||||
plan.estimated_saved_tokens,
|
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<Message> = (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<Message> = (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("<summary>"));
|
|
||||||
assert!(plan.summary_instruction.contains("<key-facts>"));
|
|
||||||
// 保存的 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<Message> = (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[..]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,11 @@ pub fn today_iso() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把「自 1970-01-01 的天数」转换为 (年, 月, 日)。Howard Hinnant 算法。
|
/// 把「自 1970-01-01 的天数」转换为 (年, 月, 日)。Howard Hinnant 算法。
|
||||||
|
/// 公开暴露以便独立测试;正常使用经 [`today_iso`] 完成。
|
||||||
/// Convert "days since 1970-01-01" into (year, month, day). Howard Hinnant's
|
/// Convert "days since 1970-01-01" into (year, month, day). Howard Hinnant's
|
||||||
/// algorithm.
|
/// algorithm. Exposed publicly so it can be tested standalone; normal use
|
||||||
fn days_to_ymd(days: i64) -> (i64, u32, u32) {
|
/// happens through [`today_iso`].
|
||||||
|
pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
let doe = (z - era * 146_097) as u64;
|
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 };
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
(y, m, d)
|
(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}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -294,127 +294,3 @@ pub fn ensure_dir(path: &Path) -> CoreResult<()> {
|
||||||
std::fs::create_dir_all(path)
|
std::fs::create_dir_all(path)
|
||||||
.map_err(|e| CoreError::Tool(format!("create dir {}: {}", path.display(), e)))
|
.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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<Message> = (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<Message> = (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("<summary>"));
|
||||||
|
assert!(plan.summary_instruction.contains("<key-facts>"));
|
||||||
|
// 保存的 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<Message> = (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[..]);
|
||||||
|
}
|
||||||
|
|
@ -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}"));
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -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<focus_core::tool::ToolResult, CoreError> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<focus_core::tool::ToolResult, CoreError> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mod common;
|
||||||
|
|
||||||
use focus_core::model::*;
|
use focus_core::model::*;
|
||||||
use focus_core::provider::{ProviderRequest, StreamEvent};
|
use focus_core::provider::{ProviderRequest, StreamEvent};
|
||||||
|
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
|
||||||
use focus_providers::anthropic::AnthropicProvider;
|
use focus_providers::anthropic::AnthropicProvider;
|
||||||
use focus_providers::config::ProviderConfig;
|
use focus_providers::config::ProviderConfig;
|
||||||
use focus_transport::TransportError;
|
use focus_transport::TransportError;
|
||||||
|
|
@ -234,4 +235,142 @@ fn request_is_well_formed() {
|
||||||
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
|
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
|
||||||
assert_eq!(body.get_str("model"), Some("claude-sonnet-4"));
|
assert_eq!(body.get_str("model"), Some("claude-sonnet-4"));
|
||||||
assert_eq!(body.get_bool("stream"), Some(true));
|
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<ToolResult, focus_core::CoreError> {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ mod common;
|
||||||
|
|
||||||
use focus_core::model::*;
|
use focus_core::model::*;
|
||||||
use focus_core::provider::{ProviderRequest, StreamEvent};
|
use focus_core::provider::{ProviderRequest, StreamEvent};
|
||||||
|
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
|
||||||
use focus_providers::config::ProviderConfig;
|
use focus_providers::config::ProviderConfig;
|
||||||
use focus_providers::openai::{OpenAiProtocol, OpenAiProvider};
|
use focus_providers::openai::{OpenAiProtocol, OpenAiProvider};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -191,3 +192,157 @@ fn request_headers_and_path() {
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(k, v)| { k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test" }));
|
.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<ToolResult, focus_core::CoreError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>) -> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -122,83 +122,3 @@ fn slice_lines(content: &str, start: Option<usize>, end: Option<usize>) -> Strin
|
||||||
}
|
}
|
||||||
lines[start_idx..end_idx].join("\n")
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -307,98 +307,3 @@ fn truncate(s: &str, max: usize) -> String {
|
||||||
out.push_str(&s.chars().skip(s.len() - half).collect::<String>());
|
out.push_str(&s.chars().skip(s.len() - half).collect::<String>());
|
||||||
out
|
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<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let sink: Box<dyn Fn(ToolUpdate) + Send + Sync> = {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>) -> 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");
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink: Box<dyn Fn(ToolUpdate) + Send + Sync> = {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -422,10 +422,13 @@ impl StreamingBody {
|
||||||
///
|
///
|
||||||
/// 字节以任意边界喂入(网络分块不保证与 HTTP 分块对齐),解码结果累积在
|
/// 字节以任意边界喂入(网络分块不保证与 HTTP 分块对齐),解码结果累积在
|
||||||
/// 内部缓冲区中,通过 [`take_decoded`](Self::take_decoded) 取走。
|
/// 内部缓冲区中,通过 [`take_decoded`](Self::take_decoded) 取走。
|
||||||
|
/// 公开暴露以便独立测试;正常使用经 [`ChunkStream`] 透明完成。
|
||||||
/// Bytes are fed at arbitrary boundaries (network chunks need not align with
|
/// Bytes are fed at arbitrary boundaries (network chunks need not align with
|
||||||
/// HTTP chunks); decoded bytes accumulate internally and are drained via
|
/// HTTP chunks); decoded bytes accumulate internally and are drained via
|
||||||
/// [`take_decoded`](Self::take_decoded).
|
/// [`take_decoded`](Self::take_decoded). Exposed publicly so it can be tested
|
||||||
pub(crate) struct ChunkedDecoder {
|
/// standalone; normal use happens transparently through [`ChunkStream`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChunkedDecoder {
|
||||||
/// 尚未消费的原始字节。
|
/// 尚未消费的原始字节。
|
||||||
/// Raw bytes not yet consumed.
|
/// Raw bytes not yet consumed.
|
||||||
raw: Vec<u8>,
|
raw: Vec<u8>,
|
||||||
|
|
@ -447,27 +450,22 @@ pub(crate) struct ChunkedDecoder {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChunkedDecoder {
|
impl ChunkedDecoder {
|
||||||
fn new() -> Self {
|
/// 创建空的解码器。
|
||||||
Self {
|
/// Create an empty decoder.
|
||||||
raw: Vec::new(),
|
pub fn new() -> Self {
|
||||||
pos: 0,
|
Self::default()
|
||||||
remaining: None,
|
|
||||||
expect_crlf: false,
|
|
||||||
finished: false,
|
|
||||||
decoded: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 喂入一块原始字节(含分块框架)。
|
/// 喂入一块原始字节(含分块框架)。
|
||||||
/// Feed a chunk of raw bytes (including chunk framing).
|
/// 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.raw.extend_from_slice(bytes);
|
||||||
self.decode();
|
self.decode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取走当前已解码的全部字节。
|
/// 取走当前已解码的全部字节。
|
||||||
/// Drain all currently decoded bytes.
|
/// Drain all currently decoded bytes.
|
||||||
fn take_decoded(&mut self) -> Option<Vec<u8>> {
|
pub fn take_decoded(&mut self) -> Option<Vec<u8>> {
|
||||||
if self.decoded.is_empty() {
|
if self.decoded.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -477,7 +475,7 @@ impl ChunkedDecoder {
|
||||||
|
|
||||||
/// 是否已到达流末尾(读到大小为 0 的终止分块)。
|
/// 是否已到达流末尾(读到大小为 0 的终止分块)。
|
||||||
/// Whether the end of the stream (size-0 terminator) has been reached.
|
/// 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
|
self.finished
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,77 +602,3 @@ fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
.windows(needle.len())
|
.windows(needle.len())
|
||||||
.position(|window| window == needle)
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,73 @@ fn decodes_single_chunk() {
|
||||||
let body = b"3\r\nabc\r\n0\r\n\r\n";
|
let body = b"3\r\nabc\r\n0\r\n\r\n";
|
||||||
assert_eq!(decode_chunked(body).unwrap(), b"abc");
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue