fix: editor agent llm resp treat null as missing
Project CI / Frontend tests (pull_request) Successful in 35s
Project CI / Repository checks (pull_request) Successful in 1m4s
Project CI / Native shell tests (pull_request) Successful in 2m33s
Project CI / Backend tests (pull_request) Successful in 3m8s

This commit is contained in:
2026-07-23 13:11:16 +08:00
parent abd98e7375
commit 80673c6f87
5 changed files with 256 additions and 22 deletions
@@ -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?;
@@ -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"));
}
}
@@ -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<T: DeserializeOwned>(
tool_name: &str,
value: &Value,
) -> Result<T, EditorAgentToolError> {
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<T: DeserializeOwned>(
label: &str,
value: &Value,
) -> Result<T, EditorAgentToolError> {
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"))
@@ -89,28 +89,39 @@ mod tests {
}
#[test]
fn default_backed_tool_args_reject_null() {
assert!(
serde_json::from_value::<GenerateImageToolArgs>(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::<GenerateVideoToolArgs>(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::<GenerateSoundEffectToolArgs>(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]
@@ -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<T: Tool + Send + Sync> ToolDyn for T {
args: serde_json::Value,
) -> Pin<Box<dyn Future<Output = ToolExecutionResult> + 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<T: Tool + Send + Sync> 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<Output = Result<Self::Output, Self::Error>> + Send {
async move { Ok(args.duration) }
}
}
#[tokio::test]
async fn dyn_tool_treats_explicit_null_as_an_omitted_default_backed_field() {
let result =
<DefaultBackedTool as ToolDyn>::call(&DefaultBackedTool, json!({ "duration": null }))
.await;
assert_eq!(result.output, json!(4));
assert_eq!(result.outcome, ToolOutcome::InternalOk);
}
}