292 lines
12 KiB
Rust
292 lines
12 KiB
Rust
#[cfg(test)]
|
||
use crate::bus::ChatMessage;
|
||
use crate::bus::OutboundMessage;
|
||
use crate::bus::message::OutboundEventKind;
|
||
#[cfg(test)]
|
||
use crate::bus::message::{ToolMessageState, format_tool_call_content};
|
||
|
||
use super::{MediaSummary, WsOutbound};
|
||
|
||
const TOOL_PENDING_RESUME_HINT: &str = "完成外部操作后,直接发一条继续消息即可。";
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutbound> {
|
||
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 {
|
||
outbound.push(WsOutbound::AssistantResponse {
|
||
id: message.id.clone(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
attachments: Vec::new(),
|
||
subagent_task_id: None,
|
||
topic_id: None,
|
||
timestamp: None,
|
||
reasoning_content: message.reasoning_content.clone(),
|
||
});
|
||
}
|
||
|
||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复
|
||
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
|
||
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
|
||
id: message.id.clone(),
|
||
tool_call_id: tool_call.id.clone(),
|
||
tool_name: tool_call.name.clone(),
|
||
arguments: tool_call.arguments.clone(),
|
||
content: format_tool_call_content(&tool_call.name, &tool_call.arguments),
|
||
role: message.role.clone(),
|
||
subagent_task_id: None,
|
||
topic_id: None,
|
||
timestamp: None,
|
||
reasoning_content: tc_reasoning.clone(),
|
||
}));
|
||
outbound
|
||
} else {
|
||
vec![WsOutbound::AssistantResponse {
|
||
id: message.id.clone(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
attachments: Vec::new(),
|
||
subagent_task_id: None,
|
||
topic_id: None,
|
||
timestamp: None,
|
||
reasoning_content: message.reasoning_content.clone(),
|
||
}]
|
||
}
|
||
}
|
||
"tool" => match message
|
||
.tool_state
|
||
.as_ref()
|
||
.unwrap_or(&ToolMessageState::Completed)
|
||
{
|
||
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
|
||
id: message.id.clone(),
|
||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
subagent_task_id: None,
|
||
topic_id: None,
|
||
duration_ms: None,
|
||
timestamp: None,
|
||
}],
|
||
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
||
id: message.id.clone(),
|
||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
||
subagent_task_id: None,
|
||
topic_id: None,
|
||
timestamp: None,
|
||
}],
|
||
},
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Vec<WsOutbound> {
|
||
match message.event_kind {
|
||
OutboundEventKind::AssistantResponse | OutboundEventKind::SchedulerNotification => {
|
||
let attachments: Vec<MediaSummary> = message
|
||
.media
|
||
.iter()
|
||
.map(|m| MediaSummary {
|
||
path: m.path.clone(),
|
||
media_type: m.media_type.clone(),
|
||
mime_type: m.mime_type.clone(),
|
||
content_base64: m.content_base64.clone(),
|
||
file_name: m.file_name.clone(),
|
||
})
|
||
.collect();
|
||
vec![WsOutbound::AssistantResponse {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
attachments,
|
||
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||
topic_id: message.metadata.get("topic_id").cloned(),
|
||
timestamp: Some(crate::protocol::now_timestamp()),
|
||
reasoning_content: message.reasoning_content.clone(),
|
||
}]
|
||
}
|
||
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
|
||
id: message
|
||
.tool_call_id
|
||
.clone()
|
||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||
arguments: message
|
||
.tool_arguments
|
||
.clone()
|
||
.unwrap_or(serde_json::Value::Null),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||
topic_id: message.metadata.get("topic_id").cloned(),
|
||
timestamp: Some(crate::protocol::now_timestamp()),
|
||
reasoning_content: message.reasoning_content.clone(),
|
||
}],
|
||
OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult {
|
||
id: message
|
||
.tool_call_id
|
||
.clone()
|
||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||
topic_id: message.metadata.get("topic_id").cloned(),
|
||
duration_ms: message
|
||
.metadata
|
||
.get("tool_duration_ms")
|
||
.and_then(|v| v.parse().ok()),
|
||
timestamp: Some(crate::protocol::now_timestamp()),
|
||
}],
|
||
OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
|
||
id: message
|
||
.tool_call_id
|
||
.clone()
|
||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||
content: message.content.clone(),
|
||
role: message.role.clone(),
|
||
resume_hint: TOOL_PENDING_RESUME_HINT.to_string(),
|
||
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||
topic_id: message.metadata.get("topic_id").cloned(),
|
||
timestamp: Some(crate::protocol::now_timestamp()),
|
||
}],
|
||
OutboundEventKind::ErrorNotification => vec![WsOutbound::Error {
|
||
code: "AGENT_ERROR".to_string(),
|
||
message: message.content.clone(),
|
||
timestamp: Some(crate::protocol::now_timestamp()),
|
||
}],
|
||
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
|
||
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
|
||
description: message.metadata.get("task_description").cloned().unwrap_or_default(),
|
||
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
||
}],
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::domain::messages::ToolCall;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn test_ws_outbound_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"}),
|
||
}],
|
||
);
|
||
|
||
let outbound = ws_outbound_from_chat_message(&message);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
match &outbound[0] {
|
||
WsOutbound::ToolCall {
|
||
tool_call_id,
|
||
tool_name,
|
||
arguments,
|
||
content,
|
||
..
|
||
} => {
|
||
assert_eq!(tool_call_id, "call-1");
|
||
assert_eq!(tool_name, "calculator");
|
||
assert_eq!(arguments["expression"], "1 + 1");
|
||
assert_eq!(content, "calculator\nargs: {\"expression\":\"1 + 1\"}");
|
||
}
|
||
other => panic!("unexpected outbound variant: {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_ws_outbound_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 = ws_outbound_from_chat_message(&message);
|
||
|
||
assert_eq!(outbound.len(), 2);
|
||
assert!(matches!(outbound[0], WsOutbound::AssistantResponse { .. }));
|
||
assert!(matches!(outbound[1], WsOutbound::ToolCall { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn test_ws_outbound_from_chat_message_includes_tool_results() {
|
||
let message = ChatMessage::tool("call-1", "calculator", "2");
|
||
|
||
let outbound = ws_outbound_from_chat_message(&message);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
assert!(matches!(outbound[0], WsOutbound::ToolResult { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn test_ws_outbound_from_chat_message_includes_tool_pending() {
|
||
let message = ChatMessage::tool_with_state(
|
||
"call-1",
|
||
"bash",
|
||
"等待你完成授权后再继续。",
|
||
ToolMessageState::PendingUserAction,
|
||
);
|
||
|
||
let outbound = ws_outbound_from_chat_message(&message);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
assert!(matches!(outbound[0], WsOutbound::ToolPending { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn test_ws_outbound_from_outbound_message_maps_tool_call() {
|
||
let message = OutboundMessage::tool_call(
|
||
"cli",
|
||
"session-1",
|
||
None, // session_id
|
||
"call-1",
|
||
"calculator",
|
||
json!({"expression": "1 + 1"}),
|
||
None,
|
||
Default::default(),
|
||
);
|
||
|
||
let outbound = ws_outbound_from_outbound_message(&message);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
match &outbound[0] {
|
||
WsOutbound::ToolCall {
|
||
tool_call_id,
|
||
tool_name,
|
||
arguments,
|
||
content,
|
||
..
|
||
} => {
|
||
assert_eq!(tool_call_id, "call-1");
|
||
assert_eq!(tool_name, "calculator");
|
||
assert_eq!(arguments["expression"], "1 + 1");
|
||
assert_eq!(content, "calculator\nargs: {\"expression\":\"1 + 1\"}");
|
||
}
|
||
other => panic!("unexpected outbound variant: {:?}", other),
|
||
}
|
||
}
|
||
}
|