66a8b7f597
* 补全完整的工具参数 * 调整提示词应对一些bad case * 把attachment的名称/描述纳入上下文 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/101 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
750 lines
26 KiB
Rust
750 lines
26 KiB
Rust
//! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。
|
||
//! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。
|
||
|
||
use serde::{Deserialize, Deserializer, Serialize};
|
||
use serde_json::json;
|
||
|
||
pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9;
|
||
pub const EDITOR_AGENT_ATTACHMENT_LABEL_MAX_CODE_POINTS: usize = 24;
|
||
pub const EDITOR_AGENT_ERROR_MESSAGE_PREFIX: &str = "ERROR ";
|
||
pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20;
|
||
pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话";
|
||
pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 2;
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum EditorAgentMessageRole {
|
||
User,
|
||
Assistant,
|
||
System,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentAttachmentSource {
|
||
CanvasResource,
|
||
LibraryAsset,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentToolCallStatus {
|
||
#[serde(alias = "pending_confirmation", alias = "executing")]
|
||
NotCompleted,
|
||
Completed,
|
||
Failed,
|
||
Cancelled,
|
||
}
|
||
|
||
fn default_editor_agent_tool_call_status() -> EditorAgentToolCallStatus {
|
||
EditorAgentToolCallStatus::NotCompleted
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentAttachmentRef {
|
||
pub source: EditorAgentAttachmentSource,
|
||
pub reference_id: String,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub label: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedImage {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedVideo {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub video_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedAudio {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub audio_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallDisplayArgs {
|
||
pub string_args: Vec<EditorAgentToolCallStringArg>,
|
||
pub image_args: Vec<EditorAgentToolCallImageArg>,
|
||
pub extras: EditorAgentToolCallDisplayExtras,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallDisplayExtras {
|
||
pub price_mud_points: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallStringArg {
|
||
pub name: String,
|
||
pub label: String,
|
||
pub value: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallImageArg {
|
||
pub name: String,
|
||
pub label: String,
|
||
pub refs: Vec<EditorAgentToolCallImageRef>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallImageRef {
|
||
pub image_id: String,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub label: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCall {
|
||
pub tool_name: String,
|
||
#[serde(default = "default_editor_agent_tool_call_status")]
|
||
pub status: EditorAgentToolCallStatus,
|
||
pub args: serde_json::Value,
|
||
pub display_args: EditorAgentToolCallDisplayArgs,
|
||
#[serde(default)]
|
||
pub external_job_id: Option<String>,
|
||
#[serde(default)]
|
||
pub images: Vec<EditorAgentGeneratedImage>,
|
||
#[serde(default)]
|
||
pub videos: Vec<EditorAgentGeneratedVideo>,
|
||
#[serde(default)]
|
||
pub audios: Vec<EditorAgentGeneratedAudio>,
|
||
#[serde(default)]
|
||
pub error: Option<String>,
|
||
}
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(untagged)]
|
||
enum RawEditorAgentMessageId {
|
||
Number(usize),
|
||
String(String),
|
||
#[allow(dead_code)]
|
||
Other(serde_json::Value),
|
||
}
|
||
|
||
impl Default for RawEditorAgentMessageId {
|
||
fn default() -> Self {
|
||
Self::Number(0)
|
||
}
|
||
}
|
||
|
||
impl RawEditorAgentMessageId {
|
||
fn numeric_id(&self) -> usize {
|
||
match self {
|
||
Self::Number(id) => *id,
|
||
Self::String(_) | Self::Other(_) => 0,
|
||
}
|
||
}
|
||
|
||
fn legacy_client_message_id(&self, role: EditorAgentMessageRole) -> Option<String> {
|
||
if role != EditorAgentMessageRole::User {
|
||
return None;
|
||
}
|
||
match self {
|
||
Self::String(id) if !id.trim().is_empty() => Some(id.trim().to_string()),
|
||
Self::Number(_) | Self::String(_) | Self::Other(_) => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessage {
|
||
// to compatible for legacy version
|
||
pub id: usize,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub client_message_id: Option<String>,
|
||
pub role: EditorAgentMessageRole,
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
// one tool call is inside one system message.
|
||
pub tool_call: Option<EditorAgentToolCall>,
|
||
pub created_at: String,
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for EditorAgentMessage {
|
||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||
where
|
||
D: Deserializer<'de>,
|
||
{
|
||
Ok(RawEditorAgentMessage::deserialize(deserializer)?.into_single_message())
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationSummary {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub updated_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationDetail {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub created_at: String,
|
||
pub updated_at: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationMessagesDocument {
|
||
pub version: u32,
|
||
pub conversation_id: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
// for legacy migration start
|
||
impl<'de> Deserialize<'de> for EditorAgentConversationMessagesDocument {
|
||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||
where
|
||
D: Deserializer<'de>,
|
||
{
|
||
let raw = RawEditorAgentConversationMessagesDocument::deserialize(deserializer)?;
|
||
let mut messages = Vec::new();
|
||
for raw_message in raw.messages {
|
||
for mut message in raw_message.into_document_messages() {
|
||
message.id = messages.len();
|
||
messages.push(message);
|
||
}
|
||
}
|
||
Ok(Self {
|
||
version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION,
|
||
conversation_id: raw.conversation_id,
|
||
messages,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct RawEditorAgentConversationMessagesDocument {
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
version: u32,
|
||
conversation_id: String,
|
||
#[serde(default)]
|
||
messages: Vec<RawEditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct RawEditorAgentMessage {
|
||
#[serde(default)]
|
||
id: RawEditorAgentMessageId,
|
||
#[serde(default)]
|
||
client_message_id: Option<String>,
|
||
role: EditorAgentMessageRole,
|
||
#[serde(default)]
|
||
text: String,
|
||
#[serde(default)]
|
||
attachments: Vec<EditorAgentAttachmentRef>,
|
||
#[serde(default)]
|
||
tool_call: Option<EditorAgentToolCall>,
|
||
#[serde(default)]
|
||
generations: Vec<LegacyEditorAgentGenerationRecord>,
|
||
#[serde(default)]
|
||
created_at: String,
|
||
}
|
||
|
||
impl RawEditorAgentMessage {
|
||
fn into_single_message(self) -> EditorAgentMessage {
|
||
let id = self.id.numeric_id();
|
||
let client_message_id = self
|
||
.client_message_id
|
||
.or_else(|| self.id.legacy_client_message_id(self.role));
|
||
let tool_call = self
|
||
.tool_call
|
||
.or_else(|| legacy_generations_to_tool_call(self.generations));
|
||
EditorAgentMessage {
|
||
id,
|
||
client_message_id,
|
||
role: self.role,
|
||
text: self.text,
|
||
attachments: self.attachments,
|
||
tool_call,
|
||
created_at: self.created_at,
|
||
}
|
||
}
|
||
|
||
fn into_document_messages(self) -> Vec<EditorAgentMessage> {
|
||
if self.tool_call.is_some() || self.generations.is_empty() {
|
||
return vec![self.into_single_message()];
|
||
}
|
||
|
||
let id = self.id.numeric_id();
|
||
let client_message_id = self
|
||
.client_message_id
|
||
.clone()
|
||
.or_else(|| self.id.legacy_client_message_id(self.role));
|
||
let mut messages = Vec::new();
|
||
if self.role != EditorAgentMessageRole::Assistant
|
||
|| !self.text.trim().is_empty()
|
||
|| !self.attachments.is_empty()
|
||
{
|
||
messages.push(EditorAgentMessage {
|
||
id,
|
||
client_message_id,
|
||
role: self.role,
|
||
text: self.text.clone(),
|
||
attachments: self.attachments,
|
||
tool_call: None,
|
||
created_at: self.created_at.clone(),
|
||
});
|
||
}
|
||
|
||
messages.extend(self.generations.into_iter().map(|generation| {
|
||
let tool_call_message =
|
||
legacy_generation_to_tool_call_message(generation, self.created_at.as_str());
|
||
EditorAgentMessage {
|
||
id,
|
||
client_message_id: None,
|
||
role: EditorAgentMessageRole::System,
|
||
text: tool_call_message.text,
|
||
attachments: Vec::new(),
|
||
tool_call: Some(tool_call_message.tool_call),
|
||
created_at: self.created_at.clone(),
|
||
}
|
||
}));
|
||
messages
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LegacyEditorAgentGenerationRecord {
|
||
#[serde(default)]
|
||
tool_call_id: String,
|
||
tool_name: String,
|
||
#[serde(default)]
|
||
summary: Option<String>,
|
||
#[serde(default)]
|
||
task_id: Option<String>,
|
||
status: LegacyEditorAgentGenerationStatus,
|
||
#[serde(default)]
|
||
model: Option<String>,
|
||
#[serde(default)]
|
||
images: Vec<EditorAgentGeneratedImage>,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
enum LegacyEditorAgentGenerationStatus {
|
||
Generating,
|
||
Completed,
|
||
Failed,
|
||
}
|
||
|
||
struct LegacyToolCallMessage {
|
||
text: String,
|
||
tool_call: EditorAgentToolCall,
|
||
}
|
||
|
||
fn legacy_generations_to_tool_call(
|
||
generations: Vec<LegacyEditorAgentGenerationRecord>,
|
||
) -> Option<EditorAgentToolCall> {
|
||
let mut generations = generations.into_iter();
|
||
let first = generations.next()?;
|
||
let mut tool_call_message = legacy_generation_to_tool_call_message(first, "");
|
||
for generation in generations {
|
||
tool_call_message.tool_call.images.extend(generation.images);
|
||
if tool_call_message.tool_call.error.is_none() {
|
||
tool_call_message.tool_call.error = generation.error;
|
||
}
|
||
if tool_call_message.tool_call.status == EditorAgentToolCallStatus::Completed
|
||
&& generation.status != LegacyEditorAgentGenerationStatus::Completed
|
||
{
|
||
tool_call_message.tool_call.status = legacy_generation_status(generation.status);
|
||
}
|
||
}
|
||
Some(tool_call_message.tool_call)
|
||
}
|
||
|
||
fn legacy_generation_to_tool_call_message(
|
||
generation: LegacyEditorAgentGenerationRecord,
|
||
created_at: &str,
|
||
) -> LegacyToolCallMessage {
|
||
let tool_name = normalize_legacy_tool_name(generation.tool_name.as_str());
|
||
let status = legacy_generation_status(generation.status);
|
||
let summary = generation.summary.clone();
|
||
let error = generation.error.clone();
|
||
let text = legacy_tool_call_text(tool_name.as_str(), summary.as_deref(), error.as_deref());
|
||
LegacyToolCallMessage {
|
||
text,
|
||
tool_call: EditorAgentToolCall {
|
||
tool_name,
|
||
status,
|
||
args: json!({
|
||
"legacyToolCallId": generation.tool_call_id,
|
||
"legacySummary": summary,
|
||
"legacyModel": generation.model,
|
||
"legacyCreatedAt": created_at,
|
||
}),
|
||
display_args: EditorAgentToolCallDisplayArgs::default(),
|
||
external_job_id: generation.task_id,
|
||
images: generation.images,
|
||
videos: Vec::new(),
|
||
audios: Vec::new(),
|
||
error,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn legacy_generation_status(
|
||
status: LegacyEditorAgentGenerationStatus,
|
||
) -> EditorAgentToolCallStatus {
|
||
match status {
|
||
LegacyEditorAgentGenerationStatus::Generating => EditorAgentToolCallStatus::NotCompleted,
|
||
LegacyEditorAgentGenerationStatus::Completed => EditorAgentToolCallStatus::Completed,
|
||
LegacyEditorAgentGenerationStatus::Failed => EditorAgentToolCallStatus::Failed,
|
||
}
|
||
}
|
||
|
||
fn normalize_legacy_tool_name(tool_name: &str) -> String {
|
||
let normalized = tool_name.trim().replace('_', "-");
|
||
if normalized.is_empty() {
|
||
"generate-image".to_string()
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn legacy_tool_call_text(tool_name: &str, summary: Option<&str>, error: Option<&str>) -> String {
|
||
if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
|
||
return format!("[tool_call:{tool_name}] output: {error}");
|
||
}
|
||
let output = summary
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("legacy generation restored");
|
||
format!("[tool_call:{tool_name}] output: {output}")
|
||
}
|
||
// for legacy migration end
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CreateEditorAgentConversationRequest {
|
||
#[serde(default)]
|
||
pub title: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationListResponse {
|
||
pub conversations: Vec<EditorAgentConversationSummary>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationResponse {
|
||
pub conversation: EditorAgentConversationDetail,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessageRequest {
|
||
pub client_message_id: String,
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessageResponse {
|
||
pub conversation: EditorAgentConversationSummary,
|
||
pub delta_messages: Vec<EditorAgentMessage>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn message_request_requires_client_message_id_and_user_message_preserves_it() {
|
||
let missing_client_message_id =
|
||
serde_json::from_value::<EditorAgentMessageRequest>(json!({
|
||
"text": "生成一张图",
|
||
"attachments": []
|
||
}));
|
||
assert!(missing_client_message_id.is_err());
|
||
|
||
let request = serde_json::from_value::<EditorAgentMessageRequest>(json!({
|
||
"clientMessageId": "client-message-1",
|
||
"text": "生成一张图",
|
||
"attachments": []
|
||
}))
|
||
.expect("clientMessageId should deserialize");
|
||
assert_eq!(request.client_message_id, "client-message-1");
|
||
|
||
let message = EditorAgentMessage {
|
||
id: 0,
|
||
client_message_id: Some(request.client_message_id),
|
||
role: EditorAgentMessageRole::User,
|
||
text: request.text,
|
||
attachments: request.attachments,
|
||
tool_call: None,
|
||
created_at: "2026-07-16T00:00:00Z".to_string(),
|
||
};
|
||
let payload = serde_json::to_value(message).expect("message should serialize");
|
||
assert_eq!(payload["id"], 0);
|
||
assert_eq!(payload["clientMessageId"], "client-message-1");
|
||
}
|
||
|
||
#[test]
|
||
fn tool_call_display_args_and_media_use_camel_case() {
|
||
let missing_display_args = serde_json::from_value::<EditorAgentToolCall>(json!({
|
||
"toolName": "generate-video",
|
||
"args": {},
|
||
"images": []
|
||
}));
|
||
assert!(missing_display_args.is_err());
|
||
|
||
let tool_call = EditorAgentToolCall {
|
||
tool_name: "generate-video".to_string(),
|
||
status: EditorAgentToolCallStatus::Completed,
|
||
args: json!({}),
|
||
display_args: EditorAgentToolCallDisplayArgs {
|
||
string_args: vec![EditorAgentToolCallStringArg {
|
||
name: "prompt".to_string(),
|
||
label: "提示词".to_string(),
|
||
value: "生成像素风角色".to_string(),
|
||
}],
|
||
image_args: vec![EditorAgentToolCallImageArg {
|
||
name: "reference_image_ids".to_string(),
|
||
label: "参考图片".to_string(),
|
||
refs: vec![EditorAgentToolCallImageRef {
|
||
image_id: "sha256:reference-1".to_string(),
|
||
image_src: "/reference.png".to_string(),
|
||
object_key: Some("generated/reference.png".to_string()),
|
||
thumbnail_src: Some("/reference-thumbnail.png".to_string()),
|
||
label: Some("角色参考".to_string()),
|
||
width: Some(512),
|
||
height: Some(512),
|
||
}],
|
||
}],
|
||
extras: EditorAgentToolCallDisplayExtras {
|
||
price_mud_points: 5,
|
||
},
|
||
},
|
||
external_job_id: Some("task-editor-agent-1".to_string()),
|
||
images: Vec::new(),
|
||
videos: vec![EditorAgentGeneratedVideo {
|
||
resource_id: Some("resource-video-1".to_string()),
|
||
object_key: Some("generated/video.mp4".to_string()),
|
||
asset_object_id: Some("asset-video-1".to_string()),
|
||
video_src: "/generated-video.mp4".to_string(),
|
||
thumbnail_src: Some("/generated-video.png".to_string()),
|
||
width: Some(1280),
|
||
height: Some(720),
|
||
}],
|
||
audios: vec![EditorAgentGeneratedAudio {
|
||
resource_id: Some("resource-audio-1".to_string()),
|
||
object_key: Some("generated/audio.mp3".to_string()),
|
||
asset_object_id: Some("asset-audio-1".to_string()),
|
||
audio_src: "/generated-audio.mp3".to_string(),
|
||
}],
|
||
error: None,
|
||
};
|
||
let payload = serde_json::to_value(tool_call).expect("tool call should serialize");
|
||
assert_eq!(payload["videos"][0]["videoSrc"], "/generated-video.mp4");
|
||
assert_eq!(payload["externalJobId"], "task-editor-agent-1");
|
||
assert_eq!(payload["status"], "completed");
|
||
assert_eq!(payload["audios"][0]["audioSrc"], "/generated-audio.mp3");
|
||
assert_eq!(
|
||
payload["displayArgs"]["stringArgs"][0]["value"],
|
||
"生成像素风角色"
|
||
);
|
||
assert_eq!(
|
||
payload["displayArgs"]["imageArgs"][0]["refs"][0]["imageId"],
|
||
"sha256:reference-1"
|
||
);
|
||
assert_eq!(
|
||
payload["displayArgs"]["imageArgs"][0]["refs"][0]["objectKey"],
|
||
"generated/reference.png"
|
||
);
|
||
assert_eq!(payload["displayArgs"]["extras"]["priceMudPoints"], 5);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_tool_call_status_maps_to_not_completed_on_read() {
|
||
let tool_call = serde_json::from_value::<EditorAgentToolCall>(json!({
|
||
"toolName": "generate-image",
|
||
"status": "executing",
|
||
"args": {},
|
||
"displayArgs": {
|
||
"stringArgs": [],
|
||
"imageArgs": [],
|
||
"extras": { "priceMudPoints": 3 }
|
||
},
|
||
"images": []
|
||
}))
|
||
.expect("legacy status should not break persisted OSS documents");
|
||
|
||
let payload = serde_json::to_value(tool_call).expect("tool call should serialize");
|
||
assert_eq!(payload["status"], "not_completed");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_message_generations_migrate_to_tool_call_media() {
|
||
let document = serde_json::from_value::<EditorAgentConversationMessagesDocument>(json!({
|
||
"version": 1,
|
||
"conversationId": "editor-agent-conv-legacy",
|
||
"messages": [
|
||
{
|
||
"id": "message-user-1",
|
||
"role": "user",
|
||
"kind": "chat",
|
||
"text": "帮我生成一张森林背景",
|
||
"attachments": [],
|
||
"generations": [],
|
||
"status": "completed",
|
||
"createdAt": "2026-07-03T00:00:00Z"
|
||
},
|
||
{
|
||
"id": "message-assistant-1",
|
||
"role": "assistant",
|
||
"kind": "chat",
|
||
"text": "已为你生成森林背景。",
|
||
"attachments": [],
|
||
"generations": [
|
||
{
|
||
"toolCallId": "tool-call-legacy-1",
|
||
"toolName": "generate_image",
|
||
"summary": "森林背景",
|
||
"taskId": "task-legacy-1",
|
||
"status": "completed",
|
||
"model": "z-image",
|
||
"images": [
|
||
{
|
||
"resourceId": "resource-legacy-1",
|
||
"objectKey": "generated/legacy-forest.png",
|
||
"assetObjectId": "asset-legacy-1",
|
||
"imageSrc": "https://example.test/legacy-forest.png",
|
||
"thumbnailSrc": "https://example.test/legacy-forest-thumb.png",
|
||
"width": 1024,
|
||
"height": 1024
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"status": "completed",
|
||
"createdAt": "2026-07-03T00:00:01Z"
|
||
}
|
||
]
|
||
}))
|
||
.expect("legacy document should deserialize");
|
||
|
||
assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION);
|
||
assert_eq!(document.messages.len(), 3);
|
||
assert_eq!(document.messages[0].id, 0);
|
||
assert_eq!(
|
||
document.messages[0].client_message_id.as_deref(),
|
||
Some("message-user-1")
|
||
);
|
||
assert_eq!(document.messages[1].id, 1);
|
||
assert!(document.messages[1].client_message_id.is_none());
|
||
assert_eq!(document.messages[1].role, EditorAgentMessageRole::Assistant);
|
||
assert_eq!(document.messages[1].text, "已为你生成森林背景。");
|
||
assert!(document.messages[1].tool_call.is_none());
|
||
assert_eq!(document.messages[2].id, 2);
|
||
assert_eq!(document.messages[2].role, EditorAgentMessageRole::System);
|
||
|
||
let tool_call = document.messages[2]
|
||
.tool_call
|
||
.as_ref()
|
||
.expect("legacy generation should become a tool call");
|
||
assert_eq!(tool_call.tool_name, "generate-image");
|
||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
|
||
assert_eq!(tool_call.external_job_id.as_deref(), Some("task-legacy-1"));
|
||
assert_eq!(tool_call.images.len(), 1);
|
||
assert_eq!(
|
||
tool_call.images[0].object_key.as_deref(),
|
||
Some("generated/legacy-forest.png")
|
||
);
|
||
assert_eq!(
|
||
tool_call.images[0].image_src,
|
||
"https://example.test/legacy-forest.png"
|
||
);
|
||
|
||
let payload = serde_json::to_value(&document).expect("document should serialize");
|
||
assert_eq!(payload["version"], EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION);
|
||
assert!(payload["messages"][1].get("generations").is_none());
|
||
assert!(payload["messages"][1].get("kind").is_none());
|
||
assert!(payload["messages"][2].get("generations").is_none());
|
||
assert_eq!(
|
||
payload["messages"][2]["toolCall"]["images"][0]["objectKey"],
|
||
"generated/legacy-forest.png"
|
||
);
|
||
}
|
||
}
|