Files
Genarrative/server-rs/crates/shared-contracts/src/editor_agent.rs
T
k88936 f5368c825f Editor agent refactored (#76)
重构了editor agent.
使得它能进行多轮工具调用, 把工具调用的结果嵌入到上下文里。
对于上下文的图片, 使用哈希后的id用来引用,不暴露细节信息to llm。
当前实现, 状态直接维护在json doc里, 需要对整个doc加锁,任务目前只能串行。
把SSE改成一个简单请求( 因为生图工具耗时需要二次确认, 不需要再实时展示给用户进度)。
增加确认/取消生图操作。
把tool的参数和错误情况做了反馈。

TODO:  工具调用的规范/prompt还可以改进。

改用external job来做素材生成, 针对来自editor agent 的生成会把生成资产的引用加入到结果json里,
客户端轮询 external job 确定生成状态, 发现结束了或者失败了就 重新get 会话,后端重新提供会话的时候把 结果插入回会话历史里,用来让 ai 引用 以及显示

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/76
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-17 21:03:15 +08:00

749 lines
26 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.
//! 画布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_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"
);
}
}