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)
This commit is contained in:
DaiChaoXiong 2026-08-09 20:59:39 +08:00
parent 7f00886150
commit 112342c42e
13 changed files with 659 additions and 320 deletions

View File

@ -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"] }

View File

@ -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<String>) {
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<Message>) {
self.messages = messages;
}
/// 发送一条用户 prompt 并运行 agent 循环至完成。
/// Send a user prompt and run the agent loop to completion.
pub fn prompt(&mut self, text: impl Into<String>, sink: &mut dyn EventSink) -> CoreResult<()> {
@ -464,39 +477,3 @@ pub fn plan_batches(calls: &[(ToolCall, ToolEffects)]) -> Vec<Vec<usize>> {
}
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]));
}
}

View File

@ -282,107 +282,3 @@ pub fn messages_from_json(value: &JsonValue) -> CoreResult<Vec<Message>> {
_ => 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());
}
}

View File

@ -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};

View File

@ -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);
}
}

View File

@ -153,9 +153,20 @@ impl fmt::Display for StreamEvent {
pub struct ProviderEventReducer {
model: String,
content: Vec<ContentBlock>,
partial_args: Vec<String>,
/// 与 `content` 对齐的参数缓冲:非工具调用槽为 `None`。
/// Argument buffer aligned with `content`; non-tool-call slots are `None`.
args_by_index: Vec<Option<String>>,
/// 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<usize>,
/// 当前打开的思考块在 `content` 中的位置。
/// Position of the currently open thinking block in `content`.
thinking_index: Option<usize>,
usage: Usage,
stop_reason: StopReason,
error_message: Option<String>,
@ -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<StreamEvent> {
/// 喂入一个思考 delta按需打开 Start + ThinkingStart。
/// Feed a thinking delta. Opens a Start + ThinkingStart as needed.
pub fn thinking_delta(&mut self, text: &str) -> Vec<StreamEvent> {
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<StreamEvent> {
/// 在 `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<StreamEvent> {
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<StreamEvent> {
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<AssistantMessage> {
// 通过解析累积的 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<StreamEvent> {
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<dyn StreamProvider> {
(**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"
);
}
}

View File

@ -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<ToolResult> {
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));
}
}

View File

@ -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<Mutex<Vec<Vec<StreamEvent>>>>,
}
impl MockProvider {
/// 用一组响应构造 mock provider。
/// Build a mock provider from a list of responses.
pub fn new(responses: Vec<Vec<StreamEvent>>) -> 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<StreamEvent>,
}
impl StreamIterator for MockStream {
fn next_event(&mut self) -> Option<StreamEvent> {
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<StreamEvent> {
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<StreamEvent> {
let model = "mock-model".to_string();
let timestamp = now_ms();
let mut content: Vec<ContentBlock> = 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 },
]
}
}

View File

@ -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]));
}

View File

@ -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());
}

View File

@ -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);
}

View File

@ -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 { .. })));
}

View File

@ -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<ToolResult> {
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));
}