From 112342c42e6a191090e23de95ffe06310b63a57b Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 20:59:39 +0800 Subject: [PATCH] feat(core): agent message setters, thinking support, reducer arg-slot fix - Agent::set_system_prompt / replace_messages (dynamic prompts + compaction) - ProviderEventReducer::thinking_delta with per-block tracking - fix: interleaved text/tool-call streams no longer misroute partial args (args aligned per content slot; regression tests added) --- crates/focus-core/Cargo.toml | 4 - crates/focus-core/src/agent.rs | 49 ++---- crates/focus-core/src/json.rs | 104 ------------ crates/focus-core/src/lib.rs | 5 - crates/focus-core/src/model.rs | 42 ----- crates/focus-core/src/provider.rs | 176 +++++++++++++------- crates/focus-core/src/tool.rs | 65 -------- crates/focus-core/tests/agent_loop.rs | 191 +++++++++++++++++++++- crates/focus-core/tests/batch_tests.rs | 37 +++++ crates/focus-core/tests/json_tests.rs | 105 ++++++++++++ crates/focus-core/tests/model_tests.rs | 41 +++++ crates/focus-core/tests/provider_tests.rs | 94 +++++++++++ crates/focus-core/tests/tool_tests.rs | 66 ++++++++ 13 files changed, 659 insertions(+), 320 deletions(-) create mode 100644 crates/focus-core/tests/batch_tests.rs create mode 100644 crates/focus-core/tests/json_tests.rs create mode 100644 crates/focus-core/tests/model_tests.rs create mode 100644 crates/focus-core/tests/provider_tests.rs create mode 100644 crates/focus-core/tests/tool_tests.rs diff --git a/crates/focus-core/Cargo.toml b/crates/focus-core/Cargo.toml index a0e700f..d64bb3d 100644 --- a/crates/focus-core/Cargo.toml +++ b/crates/focus-core/Cargo.toml @@ -6,10 +6,6 @@ rust-version.workspace = true license.workspace = true description = "Core agent layer: domain types, agent loop, tool and provider traits" -[features] -default = ["mock"] -mock = [] - [dependencies] focus-json.workspace = true tokio = { workspace = true, features = ["rt", "sync", "macros"] } diff --git a/crates/focus-core/src/agent.rs b/crates/focus-core/src/agent.rs index aa1fa9b..5dbc1ae 100644 --- a/crates/focus-core/src/agent.rs +++ b/crates/focus-core/src/agent.rs @@ -87,6 +87,19 @@ impl Agent { &self.config } + /// 更新系统提示词(例如每回合前注入动态的上下文用量信息)。 + /// Update the system prompt (e.g. to inject dynamic context-usage info + /// before each turn). + pub fn set_system_prompt(&mut self, prompt: impl Into) { + self.config.system_prompt = prompt.into(); + } + + /// 替换整条对话记录(例如上下文压缩后把摘要写回)。 + /// Replace the whole transcript (e.g. writing a compaction summary back). + pub fn replace_messages(&mut self, messages: Vec) { + self.messages = messages; + } + /// 发送一条用户 prompt 并运行 agent 循环至完成。 /// Send a user prompt and run the agent loop to completion. pub fn prompt(&mut self, text: impl Into, sink: &mut dyn EventSink) -> CoreResult<()> { @@ -464,39 +477,3 @@ pub fn plan_batches(calls: &[(ToolCall, ToolEffects)]) -> Vec> { } batches } - -#[cfg(test)] -mod batch_tests { - use super::*; - use focus_json::JsonValue; - - fn tc(name: &str) -> ToolCall { - ToolCall { - id: name.into(), - name: name.into(), - arguments: JsonValue::obj(), - } - } - - #[test] - fn two_reads_share_a_batch() { - let calls = vec![(tc("a"), ToolEffects::READ), (tc("b"), ToolEffects::READ)]; - let batches = plan_batches(&calls); - assert_eq!(batches, vec![vec![0, 1]]); - } - - #[test] - fn write_isolated() { - let calls = vec![ - (tc("a"), ToolEffects::READ), - (tc("b"), ToolEffects::WRITE), - (tc("c"), ToolEffects::READ), - ]; - let batches = plan_batches(&calls); - // a、c 一起读,b 单独写。 - // a+c read together, b write alone - assert_eq!(batches.len(), 2); - assert!(batches.iter().any(|b| b == &vec![0, 2])); - assert!(batches.iter().any(|b| b == &vec![1])); - } -} diff --git a/crates/focus-core/src/json.rs b/crates/focus-core/src/json.rs index 1000868..79f64b1 100644 --- a/crates/focus-core/src/json.rs +++ b/crates/focus-core/src/json.rs @@ -282,107 +282,3 @@ pub fn messages_from_json(value: &JsonValue) -> CoreResult> { _ => Err(CoreError::Json("expected array of messages".into())), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn text_block_roundtrip() { - let b = ContentBlock::text("hello"); - let j = b.to_json(); - let back = ContentBlock::from_json(&j).unwrap(); - assert_eq!(b, back); - } - - #[test] - fn tool_call_roundtrip() { - let mut args = JsonValue::obj(); - args.insert("path", "/tmp/x".into()).ok(); - let tc = ToolCall { - id: "call_1".into(), - name: "read".into(), - arguments: args, - }; - let j = tc.to_json(); - let back = ToolCall::from_json(&j).unwrap(); - assert_eq!(tc, back); - } - - #[test] - fn user_message_roundtrip() { - let m = Message::user_text("hello world"); - let j = m.to_json(); - let back = Message::from_json(&j).unwrap(); - assert_eq!(m, back); - } - - #[test] - fn assistant_message_with_tool_call_roundtrip() { - let mut args = JsonValue::obj(); - args.insert("cmd", "ls".into()).ok(); - let m = Message::Assistant(AssistantMessage { - content: vec![ - ContentBlock::text("running ls"), - ContentBlock::ToolCall(ToolCall { - id: "c1".into(), - name: "bash".into(), - arguments: args, - }), - ], - model: "claude-test".into(), - usage: Usage { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - stop_reason: StopReason::ToolUse, - error_message: None, - timestamp: 12345, - }); - let j = m.to_json(); - let back = Message::from_json(&j).unwrap(); - assert_eq!(m, back); - } - - #[test] - fn tool_result_roundtrip() { - let mut details = JsonValue::obj(); - details.insert("exitCode", (0.0).into()).ok(); - let m = Message::ToolResult(ToolResultMessage { - tool_call_id: "c1".into(), - tool_name: "bash".into(), - content: vec![ContentBlock::text("file1\nfile2")], - details, - is_error: false, - timestamp: 99, - }); - let j = m.to_json(); - let back = Message::from_json(&j).unwrap(); - assert_eq!(m, back); - } - - #[test] - fn messages_array_roundtrip() { - let msgs = vec![ - Message::user_text("hi"), - Message::Assistant(AssistantMessage { - content: vec![ContentBlock::text("hello")], - model: "m".into(), - usage: Usage::default(), - stop_reason: StopReason::Stop, - error_message: None, - timestamp: 1, - }), - ]; - let j = messages_to_json(&msgs); - let back = messages_from_json(&j).unwrap(); - assert_eq!(msgs, back); - } - - #[test] - fn rejects_missing_role() { - let bad = JsonValue::obj(); - assert!(Message::from_json(&bad).is_err()); - } -} diff --git a/crates/focus-core/src/lib.rs b/crates/focus-core/src/lib.rs index 626f0e5..59912c2 100644 --- a/crates/focus-core/src/lib.rs +++ b/crates/focus-core/src/lib.rs @@ -33,11 +33,6 @@ pub mod provider; /// The `Tool` trait, effects bitflag, and tool registry. pub mod tool; -// 仅在测试或 mock feature 下编译。 -// Compiled only under test or the mock feature. -#[cfg(any(test, feature = "mock"))] -pub mod mock; - pub use agent::{Agent, AgentConfig, AgentHandle}; pub use error::{CoreError, CoreResult}; pub use event::{AgentEvent, EventSink}; diff --git a/crates/focus-core/src/model.rs b/crates/focus-core/src/model.rs index 3cbf146..89b0150 100644 --- a/crates/focus-core/src/model.rs +++ b/crates/focus-core/src/model.rs @@ -312,45 +312,3 @@ pub fn now_ms() -> u64 { .map(|d| d.as_millis() as u64) .unwrap_or(0) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stop_reason_roundtrip() { - for r in [ - StopReason::Stop, - StopReason::Length, - StopReason::ToolUse, - StopReason::Error, - StopReason::Aborted, - ] { - assert_eq!(StopReason::parse_str(r.as_str()), Some(r)); - } - assert_eq!(StopReason::parse_str("bogus"), None); - } - - #[test] - fn fatal_reasons() { - assert!(StopReason::Error.is_fatal()); - assert!(StopReason::Aborted.is_fatal()); - assert!(!StopReason::Stop.is_fatal()); - assert!(!StopReason::ToolUse.is_fatal()); - } - - #[test] - fn message_role() { - assert_eq!(Message::user_text("hi").role(), "user"); - } - - #[test] - fn usage_total() { - let u = Usage { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }; - assert_eq!(u.total_tokens(), 15); - } -} diff --git a/crates/focus-core/src/provider.rs b/crates/focus-core/src/provider.rs index 7654f14..3424476 100644 --- a/crates/focus-core/src/provider.rs +++ b/crates/focus-core/src/provider.rs @@ -153,9 +153,20 @@ impl fmt::Display for StreamEvent { pub struct ProviderEventReducer { model: String, content: Vec, - partial_args: Vec, + /// 与 `content` 对齐的参数缓冲:非工具调用槽为 `None`。 + /// Argument buffer aligned with `content`; non-tool-call slots are `None`. + args_by_index: Vec>, + /// provider 局部索引 → content 位置 的映射(支持交错的多工具调用)。 + /// Mapping from provider-local index to content position (supports + /// interleaved multi-tool-call streams). + tool_call_pos: Vec<(usize, usize)>, started: bool, - text_open: bool, + /// 当前打开的文本块在 `content` 中的位置。 + /// Position of the currently open text block in `content`. + text_index: Option, + /// 当前打开的思考块在 `content` 中的位置。 + /// Position of the currently open thinking block in `content`. + thinking_index: Option, usage: Usage, stop_reason: StopReason, error_message: Option, @@ -166,9 +177,11 @@ impl ProviderEventReducer { Self { model: model.to_string(), content: Vec::new(), - partial_args: Vec::new(), + args_by_index: Vec::new(), + tool_call_pos: Vec::new(), started: false, - text_open: false, + text_index: None, + thinking_index: None, usage: Usage::default(), stop_reason: StopReason::Stop, error_message: None, @@ -207,59 +220,102 @@ impl ProviderEventReducer { if let Some(ev) = self.ensure_started() { out.push(ev); } - if !self.text_open { + if self.text_index.is_none() { self.content.push(ContentBlock::Text(TextContent { text: String::new(), signature: None, })); - self.text_open = true; + self.args_by_index.push(None); + self.text_index = Some(self.content.len() - 1); out.push(StreamEvent::TextStart { content_index: self.content.len() - 1, partial: self.partial(), }); } - if let Some(ContentBlock::Text(t)) = self.content.last_mut() { + let idx = self.text_index.expect("text block open"); + if let Some(ContentBlock::Text(t)) = self.content.get_mut(idx) { t.text.push_str(text); } out.push(StreamEvent::TextDelta { - content_index: self.content.len() - 1, + content_index: idx, delta: text.to_string(), partial: self.partial(), }); out } - /// 在 `index`(provider 本地索引,映射到一个新内容槽)处打开工具调用块。 - /// Open a tool-call block at `index` (provider-local index, mapped to a - /// new content slot). - pub fn tool_call_start(&mut self, _index: usize, name: &str) -> Vec { + /// 喂入一个思考 delta;按需打开 Start + ThinkingStart。 + /// Feed a thinking delta. Opens a Start + ThinkingStart as needed. + pub fn thinking_delta(&mut self, text: &str) -> Vec { let mut out = Vec::new(); if let Some(ev) = self.ensure_started() { out.push(ev); } - self.content.push(ContentBlock::ToolCall(ToolCall { - id: format!("call_{}", self.content.len()), - name: name.to_string(), - arguments: focus_json::JsonValue::obj(), - })); - self.partial_args.push(String::new()); - out.push(StreamEvent::ToolCallStart { - content_index: self.content.len() - 1, + if self.thinking_index.is_none() { + self.content.push(ContentBlock::Thinking(ThinkingContent { + thinking: String::new(), + signature: None, + redacted: false, + })); + self.args_by_index.push(None); + self.thinking_index = Some(self.content.len() - 1); + out.push(StreamEvent::ThinkingStart { + content_index: self.content.len() - 1, + partial: self.partial(), + }); + } + let idx = self.thinking_index.expect("thinking block open"); + if let Some(ContentBlock::Thinking(t)) = self.content.get_mut(idx) { + t.thinking.push_str(text); + } + out.push(StreamEvent::ThinkingDelta { + content_index: idx, + delta: text.to_string(), partial: self.partial(), }); out } - /// 向 `index` 处的工具调用追加部分 JSON 参数。 - /// Append partial JSON arguments to the tool call at `index`. - pub fn tool_call_delta(&mut self, _index: usize, args: &str) -> Vec { + /// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块。 + /// Open a tool-call block at `index` (provider-local index, mapped to a + /// new content slot). + pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec { let mut out = Vec::new(); - let idx = self.content.len().saturating_sub(1); - if let Some(slot) = self.partial_args.last_mut() { + if let Some(ev) = self.ensure_started() { + out.push(ev); + } + let call_id = format!("call_{}", self.tool_call_pos.len()); + self.content.push(ContentBlock::ToolCall(ToolCall { + id: call_id, + name: name.to_string(), + arguments: focus_json::JsonValue::obj(), + })); + let content_index = self.content.len() - 1; + self.args_by_index.push(Some(String::new())); + self.tool_call_pos.push((index, content_index)); + out.push(StreamEvent::ToolCallStart { + content_index, + partial: self.partial(), + }); + out + } + + /// 向 `index` 处的工具调用追加部分 JSON 参数(按 provider 局部索引寻址)。 + /// Append partial JSON arguments to the tool call at `index` (addressed by + /// the provider-local index). + pub fn tool_call_delta(&mut self, index: usize, args: &str) -> Vec { + let mut out = Vec::new(); + let pos = self + .tool_call_pos + .iter() + .find(|(i, _)| *i == index) + .map(|(_, c)| *c) + .unwrap_or_else(|| self.content.len().saturating_sub(1)); + if let Some(slot) = self.args_by_index.get_mut(pos).and_then(|s| s.as_mut()) { slot.push_str(args); } out.push(StreamEvent::ToolCallDelta { - content_index: idx, + content_index: pos, delta: args.to_string(), partial: self.partial(), }); @@ -277,9 +333,15 @@ impl ProviderEventReducer { pub fn finish(&mut self) -> Option { // 通过解析累积的 args 来关闭工具调用。 // Close tool calls by parsing their accumulated args. - for (i, args) in self.partial_args.iter().enumerate() { - if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) { - tc.arguments = focus_json::parse(args).unwrap_or(focus_json::JsonValue::obj()); + for (_, content_index) in &self.tool_call_pos { + let args = self + .args_by_index + .get(*content_index) + .and_then(|s| s.as_ref()) + .cloned() + .unwrap_or_default(); + if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(*content_index) { + tc.arguments = focus_json::parse(&args).unwrap_or(focus_json::JsonValue::obj()); } } let mut msg = self.partial(); @@ -287,28 +349,39 @@ impl ProviderEventReducer { Some(msg) } - /// 为所有未闭合的内容块(文本/工具调用)发出 end 事件;之后调用方应再调用 + /// 为所有未闭合的内容块(文本/思考/工具调用)发出 end 事件;之后调用方应再调用 /// [`finish`](Self::finish) 取得最终 Done。按顺序返回这些 end 事件。 - /// Emit end events for any open content blocks (text/tool calls), then - /// callers should call [`finish`](Self::finish) for the final message. Returns the end - /// events in order. + /// Emit end events for any open content blocks (text/thinking/tool calls), + /// then callers should call [`finish`](Self::finish) for the final message. + /// Returns the end events in order. pub fn finalize_events(&mut self) -> Vec { let mut out = Vec::new(); - if self.text_open { + if let Some(idx) = self.text_index.take() { out.push(StreamEvent::TextEnd { - content_index: self.content.len() - 1, + content_index: idx, + partial: self.partial(), + }); + } + if let Some(idx) = self.thinking_index.take() { + out.push(StreamEvent::ThinkingEnd { + content_index: idx, partial: self.partial(), }); - self.text_open = false; } // 关闭每个工具调用块。 // Close each tool call block. - for (i, args) in self.partial_args.iter().enumerate() { - if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) { - tc.arguments = focus_json::parse(args).unwrap_or(focus_json::JsonValue::obj()); + for (_, content_index) in &self.tool_call_pos { + let args = self + .args_by_index + .get(*content_index) + .and_then(|s| s.as_ref()) + .cloned() + .unwrap_or_default(); + if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(*content_index) { + tc.arguments = focus_json::parse(&args).unwrap_or(focus_json::JsonValue::obj()); let finalized = tc.clone(); out.push(StreamEvent::ToolCallEnd { - content_index: i, + content_index: *content_index, tool_call: finalized, partial: self.partial(), }); @@ -350,26 +423,3 @@ impl StreamProvider for Box { (**self).stream(request) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_event_display() { - assert_eq!( - StreamEvent::Start { - partial: AssistantMessage { - content: vec![], - model: "m".into(), - usage: Usage::default(), - stop_reason: StopReason::Stop, - error_message: None, - timestamp: 0, - } - } - .to_string(), - "start" - ); - } -} diff --git a/crates/focus-core/src/tool.rs b/crates/focus-core/src/tool.rs index 46aded4..ee9c51a 100644 --- a/crates/focus-core/src/tool.rs +++ b/crates/focus-core/src/tool.rs @@ -270,68 +270,3 @@ impl ToolRegistry { ) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Debug)] - struct EchoTool; - - impl Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - fn description(&self) -> &str { - "echoes input" - } - fn parameters(&self) -> JsonValue { - JsonValue::obj() - } - fn effects(&self) -> ToolEffects { - ToolEffects::READ - } - fn execute( - &self, - _id: &str, - args: &JsonValue, - _on_update: Option<&ToolUpdateSink>, - ) -> CoreResult { - let text = focus_json::to_string(args); - Ok(ToolResult::text(text)) - } - } - - #[test] - fn registry_lookup() { - let r = ToolRegistry::with(Box::new(EchoTool)); - assert!(r.get("echo").is_some()); - assert!(r.get("nope").is_none()); - assert_eq!(r.len(), 1); - } - - #[test] - fn tool_definitions_shape() { - let r = ToolRegistry::with(Box::new(EchoTool)); - let defs = r.tool_definitions(); - let arr = match &defs { - JsonValue::Arr(a) => a, - _ => panic!("expected array"), - }; - let first = &arr[0]; - assert_eq!(first.get_str("name"), Some("echo")); - assert_eq!(first.get_str("description"), Some("echoes input")); - } - - #[test] - fn read_effects_compatible() { - assert!(ToolEffects::READ.compatible_with(ToolEffects::READ)); - // 写与读不兼容。 - // write vs read is not compatible - assert!(!ToolEffects::WRITE.compatible_with(ToolEffects::READ)); - // 触网/开进程会阻止并行。 - // network/process block parallelism - assert!(!ToolEffects::NETWORK.compatible_with(ToolEffects::NONE)); - assert!(!ToolEffects::PROCESS.compatible_with(ToolEffects::NONE)); - } -} diff --git a/crates/focus-core/tests/agent_loop.rs b/crates/focus-core/tests/agent_loop.rs index b08666a..8a2c017 100644 --- a/crates/focus-core/tests/agent_loop.rs +++ b/crates/focus-core/tests/agent_loop.rs @@ -1,11 +1,11 @@ //! Integration tests for the agent loop, driven by the mock provider. use focus_core::event::VecSink; -use focus_core::mock::{make_text_response, make_tool_call_response, MockProvider}; use focus_core::model::*; use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_core::{Agent, AgentConfig}; use focus_json::JsonValue; +use mock::{make_text_response, make_tool_call_response, MockProvider}; /// A simple echo tool that returns its arguments as text. #[derive(Debug)] @@ -178,3 +178,192 @@ fn continue_requires_non_assistant_last_message() { let result = agent.r#continue(&mut sink); assert!(result.is_err()); } + +/// 回放脚本化事件的 mock provider。目前只有本测试文件使用,因此内联在此; +/// 若将来多个集成测试文件都需要,再抽成独立的测试工具 crate。 +/// A mock provider that replays scripted events. Only this test file uses it +/// today, so it lives inline here; extract to a shared test-utils crate if +/// multiple integration test files need it later. +mod mock { + use focus_core::model::*; + use focus_core::provider::{ + ProviderRequest, StreamEvent, StreamIterator, StreamProvider, StreamResult, + }; + use focus_core::CoreError; + use std::sync::{Arc, Mutex}; + + /// 构造一个回放「响应列表」的 mock provider——每个响应是一个回合的事件列表。 + /// Build a mock provider that replays a list of *responses*, where each + /// response is a list of events for one assistant turn. + #[derive(Debug, Clone)] + pub struct MockProvider { + /// 响应队列;每次回合消费一个。 + /// Queue of responses; each response is consumed by one turn. + responses: Arc>>>, + } + + impl MockProvider { + /// 用一组响应构造 mock provider。 + /// Build a mock provider from a list of responses. + pub fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses)), + } + } + + /// 一个总是返回单条文本响应(带停止原因)的 provider。 + /// A provider that always returns a single text response with stop reason. + pub fn single_text(text: &str, stop: StopReason) -> Self { + Self::new(vec![make_text_response(text, stop)]) + } + } + + impl StreamProvider for MockProvider { + fn stream(&self, _request: &ProviderRequest) -> StreamResult { + let mut queue = self.responses.lock().expect("mock poisoned"); + let events = queue + .first() + .cloned() + .ok_or_else(|| CoreError::Provider("mock provider exhausted".into()))?; + if !matches!( + events.last(), + Some(StreamEvent::Done { .. }) | Some(StreamEvent::Error { .. }) + ) { + return Err(CoreError::Provider( + "mock response must end with Done or Error".into(), + )); + } + queue.remove(0); + Ok(Box::new(MockStream { events })) + } + } + + /// 一个回放固定事件序列的流。 + /// A stream that replays a fixed event sequence. + struct MockStream { + events: Vec, + } + + impl StreamIterator for MockStream { + fn next_event(&mut self) -> Option { + if self.events.is_empty() { + None + } else { + Some(self.events.remove(0)) + } + } + } + + /// 构造一个简单的文本响应事件序列。 + /// Build a simple text-response event sequence. + pub fn make_text_response(text: &str, stop: StopReason) -> Vec { + let model = "mock-model".to_string(); + let timestamp = now_ms(); + let partial_empty = AssistantMessage { + content: vec![], + model: model.clone(), + usage: Usage::default(), + stop_reason: stop, + error_message: None, + timestamp, + }; + let partial = partial_empty.clone(); + let mut text_start = partial.clone(); + text_start.content.push(ContentBlock::Text(TextContent { + text: String::new(), + signature: None, + })); + let mut text_delta = text_start.clone(); + if let Some(ContentBlock::Text(t)) = text_delta.content.get_mut(0) { + t.text = text.to_string(); + } + let text_end = text_delta.clone(); + let mut final_msg = text_end.clone(); + final_msg.usage = Usage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }; + final_msg.stop_reason = stop; + vec![ + StreamEvent::Start { + partial: partial_empty, + }, + StreamEvent::TextStart { + content_index: 0, + partial: text_start, + }, + StreamEvent::TextDelta { + content_index: 0, + delta: text.into(), + partial: text_delta, + }, + StreamEvent::TextEnd { + content_index: 0, + partial: text_end, + }, + StreamEvent::Done { message: final_msg }, + ] + } + + /// 构造一个工具调用响应:模型请求调用一个工具。 + /// Build a tool-call response: the model asks to call a tool. + pub fn make_tool_call_response( + thinking_text: Option<&str>, + tool_name: &str, + tool_id: &str, + arguments: focus_json::JsonValue, + stop: StopReason, + ) -> Vec { + let model = "mock-model".to_string(); + let timestamp = now_ms(); + let mut content: Vec = Vec::new(); + if let Some(t) = thinking_text { + content.push(ContentBlock::text(t)); + } + content.push(ContentBlock::ToolCall(ToolCall { + id: tool_id.to_string(), + name: tool_name.to_string(), + arguments, + })); + let mut msg = AssistantMessage { + content: content.clone(), + model: model.clone(), + usage: Usage::default(), + stop_reason: stop, + error_message: None, + timestamp, + }; + msg.usage = Usage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }; + vec![ + StreamEvent::Start { + partial: AssistantMessage { + content: vec![], + model: model.clone(), + usage: Usage::default(), + stop_reason: stop, + error_message: None, + timestamp, + }, + }, + StreamEvent::ToolCallStart { + content_index: 0, + partial: msg.clone(), + }, + StreamEvent::ToolCallEnd { + content_index: 0, + tool_call: if let Some(ContentBlock::ToolCall(tc)) = content.last() { + tc.clone() + } else { + unreachable!() + }, + partial: msg.clone(), + }, + StreamEvent::Done { message: msg }, + ] + } +} diff --git a/crates/focus-core/tests/batch_tests.rs b/crates/focus-core/tests/batch_tests.rs new file mode 100644 index 0000000..532037a --- /dev/null +++ b/crates/focus-core/tests/batch_tests.rs @@ -0,0 +1,37 @@ +//! agent 批处理规划(`plan_batches`)的集成测试。 +//! Integration tests for agent batch planning (`plan_batches`). + +use focus_core::agent::plan_batches; +use focus_core::model::ToolCall; +use focus_core::tool::ToolEffects; +use focus_json::JsonValue; + +fn tc(name: &str) -> ToolCall { + ToolCall { + id: name.into(), + name: name.into(), + arguments: JsonValue::obj(), + } +} + +#[test] +fn two_reads_share_a_batch() { + let calls = vec![(tc("a"), ToolEffects::READ), (tc("b"), ToolEffects::READ)]; + let batches = plan_batches(&calls); + assert_eq!(batches, vec![vec![0, 1]]); +} + +#[test] +fn write_isolated() { + let calls = vec![ + (tc("a"), ToolEffects::READ), + (tc("b"), ToolEffects::WRITE), + (tc("c"), ToolEffects::READ), + ]; + let batches = plan_batches(&calls); + // a、c 一起读,b 单独写。 + // a+c read together, b write alone + assert_eq!(batches.len(), 2); + assert!(batches.iter().any(|b| b == &vec![0, 2])); + assert!(batches.iter().any(|b| b == &vec![1])); +} diff --git a/crates/focus-core/tests/json_tests.rs b/crates/focus-core/tests/json_tests.rs new file mode 100644 index 0000000..48eaa90 --- /dev/null +++ b/crates/focus-core/tests/json_tests.rs @@ -0,0 +1,105 @@ +//! json 模块(领域类型与 `JsonValue` 之间的转换)的集成测试。 +//! Integration tests for the json module (conversions between domain types and `JsonValue`). + +use focus_core::json::{messages_from_json, messages_to_json, FromJson, ToJson}; +use focus_core::model::*; +use focus_json::JsonValue; + +#[test] +fn text_block_roundtrip() { + let b = ContentBlock::text("hello"); + let j = b.to_json(); + let back = ContentBlock::from_json(&j).unwrap(); + assert_eq!(b, back); +} + +#[test] +fn tool_call_roundtrip() { + let mut args = JsonValue::obj(); + args.insert("path", "/tmp/x".into()).ok(); + let tc = ToolCall { + id: "call_1".into(), + name: "read".into(), + arguments: args, + }; + let j = tc.to_json(); + let back = ToolCall::from_json(&j).unwrap(); + assert_eq!(tc, back); +} + +#[test] +fn user_message_roundtrip() { + let m = Message::user_text("hello world"); + let j = m.to_json(); + let back = Message::from_json(&j).unwrap(); + assert_eq!(m, back); +} + +#[test] +fn assistant_message_with_tool_call_roundtrip() { + let mut args = JsonValue::obj(); + args.insert("cmd", "ls".into()).ok(); + let m = Message::Assistant(AssistantMessage { + content: vec![ + ContentBlock::text("running ls"), + ContentBlock::ToolCall(ToolCall { + id: "c1".into(), + name: "bash".into(), + arguments: args, + }), + ], + model: "claude-test".into(), + usage: Usage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }, + stop_reason: StopReason::ToolUse, + error_message: None, + timestamp: 12345, + }); + let j = m.to_json(); + let back = Message::from_json(&j).unwrap(); + assert_eq!(m, back); +} + +#[test] +fn tool_result_roundtrip() { + let mut details = JsonValue::obj(); + details.insert("exitCode", (0.0).into()).ok(); + let m = Message::ToolResult(ToolResultMessage { + tool_call_id: "c1".into(), + tool_name: "bash".into(), + content: vec![ContentBlock::text("file1\nfile2")], + details, + is_error: false, + timestamp: 99, + }); + let j = m.to_json(); + let back = Message::from_json(&j).unwrap(); + assert_eq!(m, back); +} + +#[test] +fn messages_array_roundtrip() { + let msgs = vec![ + Message::user_text("hi"), + Message::Assistant(AssistantMessage { + content: vec![ContentBlock::text("hello")], + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 1, + }), + ]; + let j = messages_to_json(&msgs); + let back = messages_from_json(&j).unwrap(); + assert_eq!(msgs, back); +} + +#[test] +fn rejects_missing_role() { + let bad = JsonValue::obj(); + assert!(Message::from_json(&bad).is_err()); +} diff --git a/crates/focus-core/tests/model_tests.rs b/crates/focus-core/tests/model_tests.rs new file mode 100644 index 0000000..c42ef64 --- /dev/null +++ b/crates/focus-core/tests/model_tests.rs @@ -0,0 +1,41 @@ +//! model 模块(领域类型)的集成测试。 +//! Integration tests for the model module (domain types). + +use focus_core::model::{Message, StopReason, Usage}; + +#[test] +fn stop_reason_roundtrip() { + for r in [ + StopReason::Stop, + StopReason::Length, + StopReason::ToolUse, + StopReason::Error, + StopReason::Aborted, + ] { + assert_eq!(StopReason::parse_str(r.as_str()), Some(r)); + } + assert_eq!(StopReason::parse_str("bogus"), None); +} + +#[test] +fn fatal_reasons() { + assert!(StopReason::Error.is_fatal()); + assert!(StopReason::Aborted.is_fatal()); + assert!(!StopReason::Stop.is_fatal()); + assert!(!StopReason::ToolUse.is_fatal()); +} + +#[test] +fn message_role() { + assert_eq!(Message::user_text("hi").role(), "user"); +} + +#[test] +fn usage_total() { + let u = Usage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }; + assert_eq!(u.total_tokens(), 15); +} diff --git a/crates/focus-core/tests/provider_tests.rs b/crates/focus-core/tests/provider_tests.rs new file mode 100644 index 0000000..98b5d17 --- /dev/null +++ b/crates/focus-core/tests/provider_tests.rs @@ -0,0 +1,94 @@ +//! provider 模块(流事件类型)的集成测试。 +//! Integration tests for the provider module (stream event types). + +use focus_core::model::{AssistantMessage, StopReason, Usage}; +use focus_core::provider::{ProviderEventReducer, StreamEvent}; + +#[test] +fn stream_event_display() { + assert_eq!( + StreamEvent::Start { + partial: AssistantMessage { + content: vec![], + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 0, + } + } + .to_string(), + "start" + ); +} + +/// 文本与工具调用交错时,参数必须归到正确的调用槽(回归测试)。 +/// When text and tool calls interleave, arguments must land in the correct +/// call slot (regression test). +#[test] +fn reducer_interleaved_text_and_tool_calls() { + let mut r = ProviderEventReducer::new("m"); + let mut events = r.text_delta("Let me check. "); + events.extend(r.tool_call_start(0, "read")); + events.extend(r.tool_call_delta(0, r#"{"path":"a""#)); + // 第二个工具调用与第一个交错。 + // Second tool call interleaved with the first. + events.extend(r.tool_call_start(1, "write")); + events.extend(r.tool_call_delta(1, r#"{"path":"b""#)); + events.extend(r.tool_call_delta(0, r#"}"#)); + events.extend(r.tool_call_delta(1, r#"}"#)); + events.extend(r.text_delta(" Done.")); + r.set_stop(StopReason::ToolUse); + events.extend(r.finalize_events()); + let msg = r.finish().expect("final message"); + + // 内容顺序:文本块 + 两个工具调用(后续文本追加到同一文本块)。 + // Content order: one text block + two tool calls (later text appends to + // the same text block). + assert_eq!(msg.content.len(), 3); + let head = msg.content[0].as_text().expect("leading text"); + assert_eq!(head.text, "Let me check. Done."); + let tc0 = msg.content[1].as_tool_call().expect("tool call 0"); + let tc1 = msg.content[2].as_tool_call().expect("tool call 1"); + assert_eq!(tc0.name, "read"); + assert_eq!(tc0.arguments.get_str("path"), Some("a")); + assert_eq!(tc1.name, "write"); + assert_eq!(tc1.arguments.get_str("path"), Some("b")); + assert_eq!(msg.stop_reason, StopReason::ToolUse); + // 事件流必须包含每个工具调用的 end 事件。 + // The event stream must include an end event per tool call. + let end_events = events + .iter() + .filter(|e| matches!(e, StreamEvent::ToolCallEnd { .. })) + .count(); + assert_eq!(end_events, 2); +} + +/// 思考块出现在文本之前时,两者都必须正确累积。 +/// When a thinking block precedes text, both must accumulate correctly. +#[test] +fn reducer_thinking_then_text() { + let mut r = ProviderEventReducer::new("m"); + let mut events = r.thinking_delta("Let me reason"); + events.extend(r.thinking_delta(" step by step.")); + events.extend(r.text_delta("Answer: 42")); + events.extend(r.finalize_events()); + let msg = r.finish().expect("final message"); + + assert_eq!(msg.content.len(), 2); + let thinking = match &msg.content[0] { + focus_core::model::ContentBlock::Thinking(t) => t.thinking.clone(), + other => panic!("expected thinking block, got {:?}", other), + }; + assert_eq!(thinking, "Let me reason step by step."); + let text = msg.content[1].as_text().expect("text block"); + assert_eq!(text.text, "Answer: 42"); + // 思考必须有自己的 start/delta/end 事件。 + // Thinking must have its own start/delta/end events. + assert!(events + .iter() + .any(|e| matches!(e, StreamEvent::ThinkingStart { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, StreamEvent::ThinkingEnd { .. }))); +} diff --git a/crates/focus-core/tests/tool_tests.rs b/crates/focus-core/tests/tool_tests.rs new file mode 100644 index 0000000..cbe6807 --- /dev/null +++ b/crates/focus-core/tests/tool_tests.rs @@ -0,0 +1,66 @@ +//! tool 模块(`Tool` trait 与注册表)的集成测试。 +//! Integration tests for the tool module (`Tool` trait and registry). + +use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; +use focus_core::CoreResult; +use focus_json::JsonValue; + +#[derive(Debug)] +struct EchoTool; + +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echoes input" + } + fn parameters(&self) -> JsonValue { + JsonValue::obj() + } + fn effects(&self) -> ToolEffects { + ToolEffects::READ + } + fn execute( + &self, + _id: &str, + args: &JsonValue, + _on_update: Option<&ToolUpdateSink>, + ) -> CoreResult { + let text = focus_json::to_string(args); + Ok(ToolResult::text(text)) + } +} + +#[test] +fn registry_lookup() { + let r = ToolRegistry::with(Box::new(EchoTool)); + assert!(r.get("echo").is_some()); + assert!(r.get("nope").is_none()); + assert_eq!(r.len(), 1); +} + +#[test] +fn tool_definitions_shape() { + let r = ToolRegistry::with(Box::new(EchoTool)); + let defs = r.tool_definitions(); + let arr = match &defs { + JsonValue::Arr(a) => a, + _ => panic!("expected array"), + }; + let first = &arr[0]; + assert_eq!(first.get_str("name"), Some("echo")); + assert_eq!(first.get_str("description"), Some("echoes input")); +} + +#[test] +fn read_effects_compatible() { + assert!(ToolEffects::READ.compatible_with(ToolEffects::READ)); + // 写与读不兼容。 + // write vs read is not compatible + assert!(!ToolEffects::WRITE.compatible_with(ToolEffects::READ)); + // 触网/开进程会阻止并行。 + // network/process block parallelism + assert!(!ToolEffects::NETWORK.compatible_with(ToolEffects::NONE)); + assert!(!ToolEffects::PROCESS.compatible_with(ToolEffects::NONE)); +}