Files
Genarrative/server-rs/crates/shared-contracts/src/editor_agent.rs
T
k88936 66a8b7f597
Project CI / Repository checks (push) Successful in 53s
Project CI / Frontend tests (push) Successful in 2m34s
Project CI / Native shell tests (push) Successful in 2m42s
Project CI / Backend tests (push) Successful in 3m28s
画布agent 完善上下文 (#101)
* 补全完整的工具参数
* 调整提示词应对一些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>
2026-07-28 15:44:05 +08:00

750 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_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"
);
}
}