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:
parent
7f00886150
commit
112342c42e
|
|
@ -6,10 +6,6 @@ rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Core agent layer: domain types, agent loop, tool and provider traits"
|
description = "Core agent layer: domain types, agent loop, tool and provider traits"
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["mock"]
|
|
||||||
mock = []
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
focus-json.workspace = true
|
focus-json.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt", "sync", "macros"] }
|
tokio = { workspace = true, features = ["rt", "sync", "macros"] }
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,19 @@ impl Agent {
|
||||||
&self.config
|
&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 循环至完成。
|
/// 发送一条用户 prompt 并运行 agent 循环至完成。
|
||||||
/// Send a user prompt and run the agent loop to completion.
|
/// 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<()> {
|
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
|
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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -282,107 +282,3 @@ pub fn messages_from_json(value: &JsonValue) -> CoreResult<Vec<Message>> {
|
||||||
_ => Err(CoreError::Json("expected array of messages".into())),
|
_ => 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,6 @@ pub mod provider;
|
||||||
/// The `Tool` trait, effects bitflag, and tool registry.
|
/// The `Tool` trait, effects bitflag, and tool registry.
|
||||||
pub mod tool;
|
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 agent::{Agent, AgentConfig, AgentHandle};
|
||||||
pub use error::{CoreError, CoreResult};
|
pub use error::{CoreError, CoreResult};
|
||||||
pub use event::{AgentEvent, EventSink};
|
pub use event::{AgentEvent, EventSink};
|
||||||
|
|
|
||||||
|
|
@ -312,45 +312,3 @@ pub fn now_ms() -> u64 {
|
||||||
.map(|d| d.as_millis() as u64)
|
.map(|d| d.as_millis() as u64)
|
||||||
.unwrap_or(0)
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,20 @@ impl fmt::Display for StreamEvent {
|
||||||
pub struct ProviderEventReducer {
|
pub struct ProviderEventReducer {
|
||||||
model: String,
|
model: String,
|
||||||
content: Vec<ContentBlock>,
|
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,
|
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,
|
usage: Usage,
|
||||||
stop_reason: StopReason,
|
stop_reason: StopReason,
|
||||||
error_message: Option<String>,
|
error_message: Option<String>,
|
||||||
|
|
@ -166,9 +177,11 @@ impl ProviderEventReducer {
|
||||||
Self {
|
Self {
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
content: Vec::new(),
|
content: Vec::new(),
|
||||||
partial_args: Vec::new(),
|
args_by_index: Vec::new(),
|
||||||
|
tool_call_pos: Vec::new(),
|
||||||
started: false,
|
started: false,
|
||||||
text_open: false,
|
text_index: None,
|
||||||
|
thinking_index: None,
|
||||||
usage: Usage::default(),
|
usage: Usage::default(),
|
||||||
stop_reason: StopReason::Stop,
|
stop_reason: StopReason::Stop,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
|
@ -207,59 +220,102 @@ impl ProviderEventReducer {
|
||||||
if let Some(ev) = self.ensure_started() {
|
if let Some(ev) = self.ensure_started() {
|
||||||
out.push(ev);
|
out.push(ev);
|
||||||
}
|
}
|
||||||
if !self.text_open {
|
if self.text_index.is_none() {
|
||||||
self.content.push(ContentBlock::Text(TextContent {
|
self.content.push(ContentBlock::Text(TextContent {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
signature: None,
|
signature: None,
|
||||||
}));
|
}));
|
||||||
self.text_open = true;
|
self.args_by_index.push(None);
|
||||||
|
self.text_index = Some(self.content.len() - 1);
|
||||||
out.push(StreamEvent::TextStart {
|
out.push(StreamEvent::TextStart {
|
||||||
content_index: self.content.len() - 1,
|
content_index: self.content.len() - 1,
|
||||||
partial: self.partial(),
|
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);
|
t.text.push_str(text);
|
||||||
}
|
}
|
||||||
out.push(StreamEvent::TextDelta {
|
out.push(StreamEvent::TextDelta {
|
||||||
content_index: self.content.len() - 1,
|
content_index: idx,
|
||||||
delta: text.to_string(),
|
delta: text.to_string(),
|
||||||
partial: self.partial(),
|
partial: self.partial(),
|
||||||
});
|
});
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在 `index`(provider 本地索引,映射到一个新内容槽)处打开工具调用块。
|
/// 喂入一个思考 delta;按需打开 Start + ThinkingStart。
|
||||||
/// Open a tool-call block at `index` (provider-local index, mapped to a
|
/// Feed a thinking delta. Opens a Start + ThinkingStart as needed.
|
||||||
/// new content slot).
|
pub fn thinking_delta(&mut self, text: &str) -> Vec<StreamEvent> {
|
||||||
pub fn tool_call_start(&mut self, _index: usize, name: &str) -> Vec<StreamEvent> {
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if let Some(ev) = self.ensure_started() {
|
if let Some(ev) = self.ensure_started() {
|
||||||
out.push(ev);
|
out.push(ev);
|
||||||
}
|
}
|
||||||
self.content.push(ContentBlock::ToolCall(ToolCall {
|
if self.thinking_index.is_none() {
|
||||||
id: format!("call_{}", self.content.len()),
|
self.content.push(ContentBlock::Thinking(ThinkingContent {
|
||||||
name: name.to_string(),
|
thinking: String::new(),
|
||||||
arguments: focus_json::JsonValue::obj(),
|
signature: None,
|
||||||
}));
|
redacted: false,
|
||||||
self.partial_args.push(String::new());
|
}));
|
||||||
out.push(StreamEvent::ToolCallStart {
|
self.args_by_index.push(None);
|
||||||
content_index: self.content.len() - 1,
|
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(),
|
partial: self.partial(),
|
||||||
});
|
});
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 向 `index` 处的工具调用追加部分 JSON 参数。
|
/// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块。
|
||||||
/// Append partial JSON arguments to the tool call at `index`.
|
/// Open a tool-call block at `index` (provider-local index, mapped to a
|
||||||
pub fn tool_call_delta(&mut self, _index: usize, args: &str) -> Vec<StreamEvent> {
|
/// new content slot).
|
||||||
|
pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec<StreamEvent> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let idx = self.content.len().saturating_sub(1);
|
if let Some(ev) = self.ensure_started() {
|
||||||
if let Some(slot) = self.partial_args.last_mut() {
|
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);
|
slot.push_str(args);
|
||||||
}
|
}
|
||||||
out.push(StreamEvent::ToolCallDelta {
|
out.push(StreamEvent::ToolCallDelta {
|
||||||
content_index: idx,
|
content_index: pos,
|
||||||
delta: args.to_string(),
|
delta: args.to_string(),
|
||||||
partial: self.partial(),
|
partial: self.partial(),
|
||||||
});
|
});
|
||||||
|
|
@ -277,9 +333,15 @@ impl ProviderEventReducer {
|
||||||
pub fn finish(&mut self) -> Option<AssistantMessage> {
|
pub fn finish(&mut self) -> Option<AssistantMessage> {
|
||||||
// 通过解析累积的 args 来关闭工具调用。
|
// 通过解析累积的 args 来关闭工具调用。
|
||||||
// Close tool calls by parsing their accumulated args.
|
// Close tool calls by parsing their accumulated args.
|
||||||
for (i, args) in self.partial_args.iter().enumerate() {
|
for (_, content_index) in &self.tool_call_pos {
|
||||||
if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) {
|
let args = self
|
||||||
tc.arguments = focus_json::parse(args).unwrap_or(focus_json::JsonValue::obj());
|
.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();
|
let mut msg = self.partial();
|
||||||
|
|
@ -287,28 +349,39 @@ impl ProviderEventReducer {
|
||||||
Some(msg)
|
Some(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 为所有未闭合的内容块(文本/工具调用)发出 end 事件;之后调用方应再调用
|
/// 为所有未闭合的内容块(文本/思考/工具调用)发出 end 事件;之后调用方应再调用
|
||||||
/// [`finish`](Self::finish) 取得最终 Done。按顺序返回这些 end 事件。
|
/// [`finish`](Self::finish) 取得最终 Done。按顺序返回这些 end 事件。
|
||||||
/// Emit end events for any open content blocks (text/tool calls), then
|
/// Emit end events for any open content blocks (text/thinking/tool calls),
|
||||||
/// callers should call [`finish`](Self::finish) for the final message. Returns the end
|
/// then callers should call [`finish`](Self::finish) for the final message.
|
||||||
/// events in order.
|
/// Returns the end events in order.
|
||||||
pub fn finalize_events(&mut self) -> Vec<StreamEvent> {
|
pub fn finalize_events(&mut self) -> Vec<StreamEvent> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if self.text_open {
|
if let Some(idx) = self.text_index.take() {
|
||||||
out.push(StreamEvent::TextEnd {
|
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(),
|
partial: self.partial(),
|
||||||
});
|
});
|
||||||
self.text_open = false;
|
|
||||||
}
|
}
|
||||||
// 关闭每个工具调用块。
|
// 关闭每个工具调用块。
|
||||||
// Close each tool call block.
|
// Close each tool call block.
|
||||||
for (i, args) in self.partial_args.iter().enumerate() {
|
for (_, content_index) in &self.tool_call_pos {
|
||||||
if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) {
|
let args = self
|
||||||
tc.arguments = focus_json::parse(args).unwrap_or(focus_json::JsonValue::obj());
|
.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();
|
let finalized = tc.clone();
|
||||||
out.push(StreamEvent::ToolCallEnd {
|
out.push(StreamEvent::ToolCallEnd {
|
||||||
content_index: i,
|
content_index: *content_index,
|
||||||
tool_call: finalized,
|
tool_call: finalized,
|
||||||
partial: self.partial(),
|
partial: self.partial(),
|
||||||
});
|
});
|
||||||
|
|
@ -350,26 +423,3 @@ impl StreamProvider for Box<dyn StreamProvider> {
|
||||||
(**self).stream(request)
|
(**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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
//! Integration tests for the agent loop, driven by the mock provider.
|
//! Integration tests for the agent loop, driven by the mock provider.
|
||||||
|
|
||||||
use focus_core::event::VecSink;
|
use focus_core::event::VecSink;
|
||||||
use focus_core::mock::{make_text_response, make_tool_call_response, MockProvider};
|
|
||||||
use focus_core::model::*;
|
use focus_core::model::*;
|
||||||
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
|
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
|
||||||
use focus_core::{Agent, AgentConfig};
|
use focus_core::{Agent, AgentConfig};
|
||||||
use focus_json::JsonValue;
|
use focus_json::JsonValue;
|
||||||
|
use mock::{make_text_response, make_tool_call_response, MockProvider};
|
||||||
|
|
||||||
/// A simple echo tool that returns its arguments as text.
|
/// A simple echo tool that returns its arguments as text.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -178,3 +178,192 @@ fn continue_requires_non_assistant_last_message() {
|
||||||
let result = agent.r#continue(&mut sink);
|
let result = agent.r#continue(&mut sink);
|
||||||
assert!(result.is_err());
|
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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 { .. })));
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue