diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 7b448bd0a..a1f0f089c 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -592,6 +592,56 @@ mod tests { assert!(!messages[0].text.contains("等待用户确认")); } + #[test] + fn pending_video_with_null_defaults_persists_and_displays_concrete_values() { + let pricing = + crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("default editor pricing should load"); + let messages = build_delta_messages( + Ok(vec![PromptOutput::Tool(ToolCallOutput { + tool_call: ToolCall { + id: "tool-call-1".to_string(), + name: GenerateVideoTool::NAME.to_string(), + args: json!({ + "prompt": "镜头向前推进", + "aspect_ratio": null, + "duration_seconds": null, + "resolution": null, + "sound": null + }), + }, + output: json!({ "message": "runner pending output" }), + })]), + "2026-07-23T00:00:00Z", + 0, + &EditorToolContext::default(), + &pricing, + ) + .expect("pending video with null defaults should build"); + + let tool_call = messages[0] + .tool_call + .as_ref() + .expect("pending message should retain its tool call"); + assert_eq!(tool_call.args["aspect_ratio"], "16:9"); + assert_eq!(tool_call.args["duration_seconds"], 4); + assert_eq!(tool_call.args["resolution"], "720p"); + assert_eq!(tool_call.args["sound"], "on"); + + let display_value = |name: &str| { + tool_call + .display_args + .string_args + .iter() + .find(|arg| arg.name == name) + .map(|arg| arg.value.as_str()) + }; + assert_eq!(display_value("aspect_ratio"), Some("16:9")); + assert_eq!(display_value("duration_seconds"), Some("4")); + assert_eq!(display_value("resolution"), Some("720p")); + assert_eq!(display_value("sound"), Some("on")); + } + #[tokio::test] async fn prompt_timeout_applies_to_the_whole_agent_run() { let error = run_editor_agent_prompt_with_timeout( @@ -1004,6 +1054,7 @@ pub async fn confirm_editor_agent_tool_call( .tool_call .as_mut() .ok_or_else(|| editor_agent_bad_request("message has no tool call"))?; + tool_call.args = normalized_args; tool_call.external_job_id = Some(job.job_id); tool_call.status = EditorAgentToolCallStatus::NotCompleted; write_messages_document(&state, &conversation, &document).await?; diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs index dbc2f9667..27a3bcec0 100644 --- a/server-rs/crates/api-server/src/editor_agent/reconcile.rs +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -210,6 +210,8 @@ fn reconcile_completed_editor_agent_tool_call( #[cfg(test)] mod tests { use super::*; + use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEffectTool; + use platform_editor_agent::framework::tool::Tool; use serde_json::json; fn pending_tool_message() -> EditorAgentMessage { @@ -298,4 +300,59 @@ mod tests { assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted); assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1")); } + + #[test] + fn completed_legacy_tool_call_with_null_defaults_still_reconciles() { + let mut message: EditorAgentMessage = serde_json::from_value(json!({ + "id": 1, + "role": "system", + "text": "waiting", + "attachments": [], + "toolCall": { + "toolName": GenerateSoundEffectTool::NAME, + "status": "not_completed", + "args": { + "prompt": "按钮点击声", + "model": null, + "duration": null + }, + "displayArgs": { + "stringArgs": [], + "imageArgs": [], + "extras": { "priceMudPoints": 5 } + }, + "externalJobId": "job-1", + "images": [], + "audios": [] + }, + "createdAt": "2026-07-16T00:00:00Z" + })) + .expect("legacy pending sound message should deserialize"); + let payload = json!({ + "editor-agent-tool-call-result": { + "ok": true, + "audioSrc": "/generated/click.mp3", + "objectKey": "generated/click.mp3", + "assetObjectId": "asset-1", + "width": 0, + "height": 0, + "sourceType": "generated", + "prompt": "按钮点击声", + "model": "audio1.0", + "provider": "vectorengine", + "taskId": "task-1", + "priceMudPoints": 5, + "audioKind": "sound-effect" + } + }) + .to_string(); + + reconcile_completed_editor_agent_tool_call(&mut message, Some(payload.as_str())) + .expect("legacy null defaults should use current tool defaults during reconciliation"); + + let tool_call = message.tool_call.expect("tool call should remain present"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed); + assert_eq!(tool_call.audios.len(), 1); + assert!(message.text.contains("\"duration\":5")); + } } diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 58fd32ef3..b1c21b879 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -27,7 +27,7 @@ use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; use platform_editor_agent::framework::error::PromptError; -use platform_editor_agent::framework::tool::{Tool, ToolDyn}; +use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing}; use platform_image::GPT_IMAGE_2_MODEL; use serde::Serialize; use serde::de::DeserializeOwned; @@ -198,7 +198,7 @@ fn parse_invalid_args( tool_name: &str, value: &Value, ) -> Result { - serde_json::from_value(value.clone()).map_err(|error| { + serde_json::from_value(null_tool_args_as_missing(value.clone())).map_err(|error| { EditorAgentToolError::invalid_args(format!( "failed to deserialize {tool_name} args: {error}" )) @@ -209,7 +209,7 @@ fn parse_internal( label: &str, value: &Value, ) -> Result { - serde_json::from_value(value.clone()).map_err(|error| { + serde_json::from_value(null_tool_args_as_missing(value.clone())).map_err(|error| { EditorAgentToolError::internal(format!("failed to deserialize {label}: {error}")) }) } @@ -1295,6 +1295,61 @@ mod tests { ); } + #[test] + fn dyn_validation_treats_explicit_null_as_missing_before_canonical_persistence() { + let image = editor_agent_tool(GenerateImageTool::NAME, &EditorToolContext::default()) + .expect("image tool should resolve"); + let image_args = image + .validate_args(&json!({ + "prompt": "生成森林场景", + "model": null, + "reference_image_ids": null, + "aspect_ratio": null, + "image_size": null + })) + .expect("null image defaults should normalize"); + assert_eq!(image_args["model"], platform_image::NANOBANANA_2_MODEL); + assert_eq!(image_args["reference_image_ids"], json!([])); + assert_eq!(image_args["aspect_ratio"], "1:1"); + assert_eq!(image_args["image_size"], "1K"); + + let video = editor_agent_tool(GenerateVideoTool::NAME, &EditorToolContext::default()) + .expect("video tool should resolve"); + let video_args = video + .validate_args(&json!({ + "prompt": "镜头向前推进", + "model": null, + "aspect_ratio": null, + "duration_seconds": null, + "resolution": null, + "sound": null + })) + .expect("null video defaults should normalize"); + assert_eq!(video_args["model"], GenerateVideoTool::DEFAULT_VIDEO_MODEL); + assert_eq!(video_args["aspect_ratio"], "16:9"); + assert_eq!(video_args["duration_seconds"], 4); + assert_eq!(video_args["resolution"], "720p"); + assert_eq!(video_args["sound"], "on"); + + let sound = editor_agent_tool(GenerateSoundEffectTool::NAME, &EditorToolContext::default()) + .expect("sound tool should resolve"); + let sound_args = sound + .validate_args(&json!({ + "prompt": "按钮点击声", + "model": null, + "duration": null + })) + .expect("null sound defaults should normalize"); + assert_eq!(sound_args["model"], GenerateSoundEffectTool::DEFAULT_MODEL); + assert_eq!(sound_args["duration"], 5); + + assert!( + sound + .validate_args(&json!({ "prompt": null, "duration": null })) + .is_err() + ); + } + #[test] fn dyn_validation_uses_the_context_bound_to_the_concrete_tool() { let tool = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1")) diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index 5c0436723..c46cc3897 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -89,28 +89,39 @@ mod tests { } #[test] - fn default_backed_tool_args_reject_null() { - assert!( - serde_json::from_value::(json!({ + fn default_backed_tool_args_treat_null_as_missing_at_the_tool_boundary() { + use crate::framework::tool::null_tool_args_as_missing; + + let image: GenerateImageToolArgs = + serde_json::from_value(null_tool_args_as_missing(json!({ "prompt": "生成森林场景", - "aspect_ratio": null - })) - .is_err() - ); - assert!( - serde_json::from_value::(json!({ + "aspect_ratio": null, + "image_size": null + }))) + .expect("null image defaults should deserialize as omitted fields"); + let video: GenerateVideoToolArgs = + serde_json::from_value(null_tool_args_as_missing(json!({ "prompt": "镜头向前推进", - "duration_seconds": null - })) - .is_err() - ); - assert!( - serde_json::from_value::(json!({ + "aspect_ratio": null, + "duration_seconds": null, + "resolution": null, + "sound": null + }))) + .expect("null video defaults should deserialize as omitted fields"); + let sound: GenerateSoundEffectToolArgs = + serde_json::from_value(null_tool_args_as_missing(json!({ "prompt": "按钮点击声", "duration": null - })) - .is_err() - ); + }))) + .expect("null sound defaults should deserialize as omitted fields"); + + assert_eq!(image.aspect_ratio, "1:1"); + assert_eq!(image.image_size, "1K"); + assert_eq!(video.aspect_ratio, "16:9"); + assert_eq!(video.duration_seconds, 4); + assert_eq!(video.resolution, "720p"); + assert_eq!(video.sound, "on"); + assert_eq!(sound.duration, 5); } #[test] diff --git a/server-rs/crates/platform-editor-agent/src/framework/tool.rs b/server-rs/crates/platform-editor-agent/src/framework/tool.rs index 8eb4cf91b..c36e76186 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/tool.rs +++ b/server-rs/crates/platform-editor-agent/src/framework/tool.rs @@ -1,6 +1,14 @@ use serde::{Deserialize, Serialize}; use std::pin::Pin; +// Treat explicit top-level JSON `null` tool arguments as omitted fields, for compatibility with llm +pub fn null_tool_args_as_missing(mut args: serde_json::Value) -> serde_json::Value { + if let serde_json::Value::Object(fields) = &mut args { + fields.retain(|_, value| !value.is_null()); + } + args +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, @@ -183,7 +191,7 @@ impl ToolDyn for T { args: serde_json::Value, ) -> Pin + Send + '_>> { Box::pin(async move { - let parsed: T::Args = match serde_json::from_value(args) { + let parsed: T::Args = match serde_json::from_value(null_tool_args_as_missing(args)) { Ok(parsed) => parsed, Err(error) => { return ToolExecutionResult::failed( @@ -213,3 +221,55 @@ impl ToolDyn for T { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use serde_json::json; + use std::convert::Infallible; + + #[derive(Deserialize)] + struct DefaultBackedArgs { + #[serde(default = "default_duration")] + duration: u32, + } + + fn default_duration() -> u32 { + 4 + } + + struct DefaultBackedTool; + + impl Tool for DefaultBackedTool { + const NAME: &'static str = "default-backed-tool"; + type Error = Infallible; + type Args = DefaultBackedArgs; + type Output = u32; + + fn description(&self) -> String { + "test default-backed tool".to_string() + } + + fn parameters(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { Ok(args.duration) } + } + } + + #[tokio::test] + async fn dyn_tool_treats_explicit_null_as_an_omitted_default_backed_field() { + let result = + ::call(&DefaultBackedTool, json!({ "duration": null })) + .await; + + assert_eq!(result.output, json!(4)); + assert_eq!(result.outcome, ToolOutcome::InternalOk); + } +}