PicoBot/src/bus/message.rs
oudecheng 994db87f11 feat: 新增 ExecutionCompleted 信号修复发送按钮状态
- 后端 OutboundEventKind/WsOutbound 新增 ExecutionCompleted 变体
- processor 在 handle_message 完成后发送该信号
- 飞书/微信通道过滤该信号(不发送)
- 前端 useChat 移除 stream_delta/assistant_response 的 isLoading=false
- 改由 execution_completed 事件统一设置 isLoading=false,确保智能体迭代期间按钮保持停止态
2026-07-03 19:32:14 +08:00

925 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::domain::messages::ToolCall;
pub const SYSTEM_CONTEXT_AGENT_PROMPT: &str = "agent_prompt";
pub const SYSTEM_CONTEXT_SCHEDULED_PROMPT: &str = "scheduled_system_prompt";
pub const SYSTEM_CONTEXT_HISTORY_COMPACTION: &str = "history_compaction";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolMessageState {
Completed,
PendingUserAction,
}
// ============================================================================
// MediaItem - Media metadata for messages
// ============================================================================
#[derive(Debug, Clone)]
pub struct MediaItem {
pub path: String, // Local file path
pub media_type: String, // "image", "audio", "file", "video"
pub mime_type: Option<String>,
pub original_key: Option<String>, // Feishu file_key for download
pub content_base64: Option<String>, // Base64-encoded file content for web download
pub file_name: Option<String>, // Display file name
}
impl MediaItem {
pub fn new(path: impl Into<String>, media_type: impl Into<String>) -> Self {
Self {
path: path.into(),
media_type: media_type.into(),
mime_type: None,
original_key: None,
content_base64: None,
file_name: None,
}
}
}
// ============================================================================
// ChatMessage - Used by AgentLoop for LLM conversation history
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub role: String,
pub content: String,
pub media_refs: Vec<String>, // Paths to media files for context
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_state: Option<ToolMessageState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "user".to_string(),
content: content.into(),
media_refs: Vec::new(),
timestamp: current_timestamp(),
system_context: None,
reasoning_content: None,
tool_call_id: None,
tool_name: None,
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
}
}
pub fn user_with_media(content: impl Into<String>, media_refs: Vec<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "user".to_string(),
content: content.into(),
media_refs,
timestamp: current_timestamp(),
system_context: None,
reasoning_content: None,
tool_call_id: None,
tool_name: None,
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "assistant".to_string(),
content: content.into(),
media_refs: Vec::new(),
timestamp: current_timestamp(),
system_context: None,
reasoning_content: None,
tool_call_id: None,
tool_name: None,
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
}
}
pub fn assistant_with_reasoning(
content: impl Into<String>,
reasoning_content: impl Into<String>,
) -> Self {
let mut message = Self::assistant(content);
message.reasoning_content = Some(reasoning_content.into());
message
}
pub fn assistant_with_tool_calls(
content: impl Into<String>,
tool_calls: Vec<ToolCall>,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "assistant".to_string(),
content: content.into(),
media_refs: Vec::new(),
timestamp: current_timestamp(),
system_context: None,
reasoning_content: None,
tool_call_id: None,
tool_name: None,
tool_duration_ms: None,
tool_state: None,
tool_calls: Some(tool_calls),
}
}
pub fn assistant_with_tool_calls_and_reasoning(
content: impl Into<String>,
tool_calls: Vec<ToolCall>,
reasoning_content: impl Into<String>,
) -> Self {
let mut message = Self::assistant_with_tool_calls(content, tool_calls);
message.reasoning_content = Some(reasoning_content.into());
message
}
pub fn system(content: impl Into<String>) -> Self {
Self::system_with_context(content, None::<String>)
}
pub fn system_with_context(
content: impl Into<String>,
system_context: impl Into<Option<String>>,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "system".to_string(),
content: content.into(),
media_refs: Vec::new(),
timestamp: current_timestamp(),
system_context: system_context.into(),
reasoning_content: None,
tool_call_id: None,
tool_name: None,
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
}
}
pub fn tool(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::tool_with_state(
tool_call_id,
tool_name,
content,
ToolMessageState::Completed,
)
}
pub fn tool_with_state(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
tool_state: ToolMessageState,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
role: "tool".to_string(),
content: content.into(),
media_refs: Vec::new(),
timestamp: current_timestamp(),
system_context: None,
reasoning_content: None,
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name.into()),
tool_duration_ms: None,
tool_state: Some(tool_state),
tool_calls: None,
}
}
pub fn with_tool_duration(mut self, ms: u64) -> Self {
self.tool_duration_ms = Some(ms);
self
}
pub fn has_system_context(&self, expected: &str) -> bool {
self.system_context.as_deref() == Some(expected)
}
pub fn is_assistant_tool_call_message(&self) -> bool {
self.role == "assistant"
&& self
.tool_calls
.as_ref()
.map(|calls| !calls.is_empty())
.unwrap_or(false)
}
}
// ============================================================================
// Message sanitization
// ============================================================================
/// Sanitize message history by removing assistant messages with `tool_calls`
/// that don't have corresponding tool result messages, at ANY position in
/// the history (not just trailing).
///
/// Incomplete sequences can appear in the middle of history when:
/// 1. The process was interrupted mid-execution, then a new user message
/// was appended, burying the orphan.
/// 2. History compaction preserves orphaned `tool_calls` from a pre-fix era
/// or from a race condition between persistence and snapshot.
///
/// Sending such incomplete sequences to the API causes errors like
/// "insufficient tool messages following tool_calls message".
///
/// Returns the number of messages removed.
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
let mut removed = 0;
// Phase 1: Single reverse pass to find ALL assistant messages with
// incomplete tool_calls, regardless of position.
//
// Scanning right-to-left means we encounter tool results before their
// parent assistants, so we naturally know which tool_call_ids have
// corresponding results.
let mut resolved_ids: HashSet<String> = HashSet::new();
let mut with_parent: HashSet<String> = HashSet::new();
let mut remove_indices: Vec<usize> = Vec::new();
// Reverse pass — collect tool result IDs first, then validate each
// assistant's tool_calls against already-seen results.
//
// Because we scan right-to-left, any tool result we've already seen
// appears AFTER the current message in forward order. This correctly
// identifies which assistant tool_calls have corresponding results.
for i in (0..messages.len()).rev() {
let msg = &messages[i];
if msg.role == "tool" {
if let Some(ref tc_id) = msg.tool_call_id {
resolved_ids.insert(tc_id.clone());
}
}
if msg.role == "assistant"
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
{
let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls
.iter()
.all(|tc| resolved_ids.contains(&tc.id));
if all_have_results {
for tc in tool_calls.iter() {
with_parent.insert(tc.id.clone());
}
} else {
let missing_count = tool_calls
.iter()
.filter(|tc| !resolved_ids.contains(&tc.id))
.count();
tracing::warn!(
tool_call_count = tool_calls.len(),
missing_tool_results = missing_count,
message_id = %msg.id,
message_index = i,
"Removing assistant message with incomplete tool call sequence — \
tool results were never persisted (likely due to process interruption \
or history compaction preserving an orphan)"
);
remove_indices.push(i);
}
}
}
// Remove in descending index order to avoid shifting
for &idx in &remove_indices {
messages.remove(idx);
removed += 1;
}
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
// trailing ones). A tool message is orphaned if its tool_call_id has no
// matching parent assistant remaining in the history.
//
// Always execute this pass unconditionally — even when Phase 1 found no
// assistant messages with tool_calls (e.g., after heavy compaction that
// summarized all tool_call sequences into text), there may still be
// orphaned tool result messages in the history that must be cleaned up.
{
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if msg.role == "tool" {
let is_orphaned = match &msg.tool_call_id {
Some(tc_id) => !with_parent.contains(tc_id),
None => true,
};
if is_orphaned {
tracing::warn!(
tool_call_id = ?msg.tool_call_id,
message_id = %msg.id,
message_index = i,
"Removing orphaned tool result message — its parent assistant \
tool_calls message was removed or never persisted"
);
messages.remove(i);
removed += 1;
continue;
}
}
i += 1;
}
}
removed
}
// ============================================================================
// InboundMessage - Message from Channel to Bus (user input)
// ============================================================================
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub channel: String,
pub sender_id: String,
pub chat_id: String,
pub content: String,
pub timestamp: i64,
pub media: Vec<MediaItem>,
/// Channel-specific data used internally by the channel (not forwarded).
pub metadata: HashMap<String, String>,
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
pub forwarded_metadata: HashMap<String, String>,
}
impl InboundMessage {
pub fn session_key(&self) -> String {
format!("{}:{}", self.channel, self.chat_id)
}
}
// ============================================================================
// OutboundMessage - Message from Agent to Channel (bot response)
// ============================================================================
#[derive(Debug, Clone)]
pub struct OutboundMessage {
pub channel: String,
/// 消息发送目标 ID如飞书 open_id、微信 chat_id
/// 注意:这始终是原始入站消息的 chat_id不会被修改为会话 ID
pub chat_id: String,
/// 内部会话 ID对应 sessions.id
/// 用于会话管理和消息持久化,与消息发送目标无关
pub session_id: Option<String>,
pub content: String,
pub reply_to: Option<String>,
pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>,
pub event_kind: OutboundEventKind,
pub role: String,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_arguments: Option<serde_json::Value>,
pub reasoning_content: Option<String>,
/// Carry the originating ChatMessage.id so the WS layer can use it
/// instead of generating a random UUID. Critical for stream delta → assistant_response
/// ID matching on the front-end.
pub message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutboundEventKind {
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
SchedulerNotification,
ErrorNotification,
TaskStarted,
/// 流式文本增量
StreamDelta,
/// 流式结束信号
StreamEnd,
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
ExecutionCompleted,
}
impl OutboundMessage {
pub fn is_stream_delta(&self) -> bool {
matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd)
}
pub fn assistant(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: content.into(),
reply_to,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::AssistantResponse,
role: "assistant".to_string(),
tool_call_id: None,
tool_name: None,
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
pub fn scheduler_notification(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::SchedulerNotification;
message
}
pub fn error_notification(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::ErrorNotification;
message
}
pub fn tool_call(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
message_id: impl Into<String>,
tool_name: impl Into<String>,
tool_arguments: serde_json::Value,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let tool_name = tool_name.into();
let content = format_tool_call_content(&tool_name, &tool_arguments);
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content,
reply_to,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ToolCall,
role: "assistant".to_string(),
tool_call_id: Some(message_id.into()),
tool_name: Some(tool_name),
tool_arguments: Some(tool_arguments),
reasoning_content: None,
message_id: None,
}
}
pub fn tool_result(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let tool_name = tool_name.into();
let raw_content = content.into();
let content = format_tool_result_content(&tool_name, &raw_content);
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content,
reply_to,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ToolResult,
role: "tool".to_string(),
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name),
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
pub fn tool_pending(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let tool_name = tool_name.into();
let raw_content = content.into();
let content = format_tool_result_content(&tool_name, &raw_content);
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content,
reply_to,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ToolPending,
role: "tool".to_string(),
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name),
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
/// 构造流式文本增量消息
pub fn stream_delta(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
message_id: impl Into<String>,
delta: impl Into<String>,
reasoning_delta: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: delta.into(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::StreamDelta,
role: "assistant".to_string(),
tool_call_id: Some(message_id.into()),
tool_name: None,
tool_arguments: None,
reasoning_content: reasoning_delta,
message_id: None,
}
}
/// 构造流式结束信号
pub fn stream_end(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
message_id: impl Into<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: String::new(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::StreamEnd,
role: "assistant".to_string(),
tool_call_id: Some(message_id.into()),
tool_name: None,
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
/// 构造执行完成信号
pub fn execution_completed(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: String::new(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ExecutionCompleted,
role: "assistant".to_string(),
tool_call_id: None,
tool_name: None,
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
pub fn from_chat_message(
channel: &str,
chat_id: &str,
session_id: Option<String>,
reply_to: Option<String>,
metadata: &HashMap<String, String>,
message: &ChatMessage,
) -> Vec<Self> {
match message.role.as_str() {
"assistant" => {
if let Some(tool_calls) = &message.tool_calls {
let mut outbound = Vec::new();
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
if has_content_or_reasoning {
let mut resp = Self::assistant(
channel.to_string(),
chat_id.to_string(),
session_id.clone(),
message.content.clone(),
reply_to.clone(),
metadata.clone(),
);
resp.reasoning_content = message.reasoning_content.clone();
resp.message_id = Some(message.id.clone());
outbound.push(resp);
}
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复;
// 只有 AssistantResponse 没发时ToolCall 才带 reasoning
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
outbound.extend(tool_calls.iter().map(|tool_call| {
let mut tc = Self::tool_call(
channel.to_string(),
chat_id.to_string(),
session_id.clone(),
tool_call.id.clone(),
tool_call.name.clone(),
tool_call.arguments.clone(),
reply_to.clone(),
metadata.clone(),
);
tc.reasoning_content = tc_reasoning.clone();
tc
}));
outbound
} else {
let mut resp = Self::assistant(
channel.to_string(),
chat_id.to_string(),
session_id,
message.content.clone(),
reply_to,
metadata.clone(),
);
resp.reasoning_content = message.reasoning_content.clone();
resp.message_id = Some(message.id.clone());
vec![resp]
}
}
"tool" => match message
.tool_state
.as_ref()
.unwrap_or(&ToolMessageState::Completed)
{
ToolMessageState::Completed => vec![Self::tool_result(
channel.to_string(),
chat_id.to_string(),
session_id,
message.tool_call_id.clone().unwrap_or_default(),
message.tool_name.clone().unwrap_or_default(),
message.content.clone(),
reply_to,
metadata.clone(),
)],
ToolMessageState::PendingUserAction => vec![Self::tool_pending(
channel.to_string(),
chat_id.to_string(),
session_id,
message.tool_call_id.clone().unwrap_or_default(),
message.tool_name.clone().unwrap_or_default(),
message.content.clone(),
reply_to,
metadata.clone(),
)],
},
_ => Vec::new(),
}
}
}
pub(crate) fn format_tool_call_content(
tool_name: &str,
tool_arguments: &serde_json::Value,
) -> String {
match tool_arguments {
serde_json::Value::Object(map) if map.is_empty() => tool_name.to_string(),
other => format!("{}\nargs: {}", tool_name, format_tool_arguments_json(other)),
}
}
fn format_tool_result_content(tool_name: &str, content: &str) -> String {
format!("工具结果: {}\n\n{}", tool_name, content)
}
fn format_tool_argument_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Null => "null".to_string(),
other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
}
}
fn format_tool_arguments_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let body = entries
.into_iter()
.map(|(key, value)| {
format!(
"{}:{}",
serde_json::to_string(key).unwrap_or_else(|_| format!("\"{}\"", key)),
serde_json::to_string(value).unwrap_or_else(|_| value.to_string()),
)
})
.collect::<Vec<_>>()
.join(",");
format!("{{{}}}", body)
}
other => format_tool_argument_value(other),
}
}
// ============================================================================
// Helpers
// ============================================================================
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::{ChatMessage, OutboundEventKind, OutboundMessage, ToolMessageState};
use crate::domain::messages::ToolCall;
use serde_json::json;
use std::collections::HashMap;
const TEST_CHANNEL: &str = "test-channel";
#[test]
fn test_from_chat_message_expands_tool_calls() {
let message = ChatMessage::assistant_with_tool_calls(
"",
vec![
ToolCall {
id: "call-1".to_string(),
name: "calculator".to_string(),
arguments: json!({"expression": "1 + 1"}),
},
ToolCall {
id: "call-2".to_string(),
name: "read".to_string(),
arguments: json!({"path": "README.md"}),
},
],
);
let outbound = OutboundMessage::from_chat_message(
TEST_CHANNEL,
"chat-1",
None,
None,
&HashMap::new(),
&message,
);
assert_eq!(outbound.len(), 2);
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolCall);
assert_eq!(outbound[0].tool_name.as_deref(), Some("calculator"));
assert_eq!(
outbound[0].tool_arguments.as_ref().unwrap()["expression"],
"1 + 1"
);
assert_eq!(
outbound[0].content,
"calculator\nargs: {\"expression\":\"1 + 1\"}"
);
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
assert_eq!(
outbound[1].content,
"read\nargs: {\"path\":\"README.md\"}"
);
}
#[test]
fn test_from_chat_message_keeps_assistant_content_when_tool_calls_exist() {
let message = ChatMessage::assistant_with_tool_calls(
"日报已整理完成。",
vec![ToolCall {
id: "call-1".to_string(),
name: "memory_manage".to_string(),
arguments: json!({"action": "put"}),
}],
);
let outbound = OutboundMessage::from_chat_message(
TEST_CHANNEL,
"chat-1",
None,
None,
&HashMap::new(),
&message,
);
assert_eq!(outbound.len(), 2);
assert_eq!(outbound[0].event_kind, OutboundEventKind::AssistantResponse);
assert_eq!(outbound[0].content, "日报已整理完成。");
assert_eq!(outbound[1].event_kind, OutboundEventKind::ToolCall);
assert_eq!(outbound[1].tool_name.as_deref(), Some("memory_manage"));
}
#[test]
fn test_from_chat_message_includes_tool_result() {
let message = ChatMessage::tool("call-9", "calculator", "2");
let outbound = OutboundMessage::from_chat_message(
TEST_CHANNEL,
"chat-1",
None,
None,
&HashMap::new(),
&message,
);
assert_eq!(outbound.len(), 1);
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolResult);
}
#[test]
fn test_from_chat_message_includes_tool_pending() {
let message = ChatMessage::tool_with_state(
"call-9",
"bash",
"等待你完成浏览器授权后再继续。",
ToolMessageState::PendingUserAction,
);
let outbound = OutboundMessage::from_chat_message(
TEST_CHANNEL,
"chat-1",
None,
None,
&HashMap::new(),
&message,
);
assert_eq!(outbound.len(), 1);
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolPending);
}
}