修复画布 Agent 音频回填丢失 provider
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

原子提交与 worker 直接完成两条路径共用同一份结果紧凑化实现,删除重复副本。

紧凑化对 Agent 消费者保留音频必填 provider,历史上该豁免只加在 worker 副本里,原子化改造新写的副本没带上。

新增 Agent 音频紧凑结果仍可被 EditorAudioGenerateResponse 反序列化的契约测试,覆盖音效与背景音乐。
This commit is contained in:
2026-08-08 07:40:27 +00:00
parent 8f4cce288c
commit 1664bc6bc8
2 changed files with 69 additions and 74 deletions
@@ -31,6 +31,8 @@ use platform_oss::{
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
#[cfg(test)]
use shared_contracts::assets::EditorAudioGenerateResponse;
use shared_contracts::assets::{
EDITOR_ASSET_LABEL_MAX_CHARS,
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
@@ -1127,11 +1129,22 @@ fn serialize_atomic_editor_generation_job_result(
Ok(payload_json)
}
fn compact_editor_generation_result(mut result: Value) -> Value {
/// 队列结果落库前的通用紧凑化。原子提交与 worker 直接 complete 两条路径共用同一份实现,
/// 避免其中一条单独演进后丢掉音频豁免(历史上就出现过:豁免只加在 worker 副本里,
/// 原子化改造新写的副本没带上,导致 Agent 音效成功却回填失败)。
pub(crate) fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
object.remove("provider");
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
let is_audio_result = object
.get("audioKind")
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
if !is_audio_result {
object.remove("provider");
}
if object
.get("model")
.and_then(Value::as_str)
@@ -19695,6 +19708,57 @@ mod tests {
}
}
/// 画布 Agent 音频回填读的是完整 `EditorAudioGenerateResponse`,其 `provider` 是必填。
/// 紧凑化对 External API 消费者隐藏 provider(见上一条),但对 Agent 消费者必须保留,
/// 否则 ElevenLabs、扣费、OSS 与画布写回全部成功之后,卡片仍会在四次回填尝试后显示失败。
#[test]
fn atomic_agent_audio_result_keeps_provider_for_tool_call_backfill() {
for audio_kind in ["sound-effect", "background-music"] {
let mut job = atomic_editor_generation_job_fixture();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
.to_string();
let context = EditorGenerationQueueResultContext::from_job(&job);
let payload: Value = serde_json::from_str(
serialize_atomic_editor_generation_job_result(
&context,
&json!({
"ok": true,
"audioSrc": "/generated/editor/audio.mp3",
"objectKey": "generated/editor/audio.mp3",
"assetObjectId": "asset-object-audio",
"width": 420,
"height": 120,
"sourceType": "generated",
"prompt": "按钮点击声",
"actualPrompt": "button click",
"model": "eleven_text_to_sound_v2",
"provider": "elevenlabs",
"taskId": "job-audio-1",
"priceMudPoints": 5,
"audioKind": audio_kind,
"durationSeconds": 3.5,
"loop": true,
}),
)
.expect("agent audio compact payload")
.as_str(),
)
.expect("agent audio compact payload should be JSON");
let result = &payload["editor-agent-tool-call-result"];
assert_eq!(result["provider"], "elevenlabs", "{audio_kind}");
// 真正的锁:生产端紧凑化后的 payload 必须仍能被消费端的 DTO 反序列化。
let backfilled: EditorAudioGenerateResponse = serde_json::from_value(result.clone())
.unwrap_or_else(|error| {
panic!("{audio_kind} agent payload must stay backfillable: {error}")
});
assert_eq!(backfilled.provider, "elevenlabs");
assert_eq!(backfilled.audio_kind, audio_kind);
}
}
#[test]
fn atomic_icon_ui_results_keep_required_consumer_fields() {
let source = include_str!("editor_project.rs");
@@ -44,9 +44,9 @@ use crate::{
EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter,
EditorGenerationQueueResultContext, EditorIconSpritesheetGenerationRequest,
EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
remove_editor_image_background_for_owner,
compact_editor_generation_result, edit_editor_image_for_owner,
extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner,
generate_editor_image_for_owner, remove_editor_image_background_for_owner,
},
request_context::RequestContext,
state::AppState,
@@ -1222,75 +1222,6 @@ fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
})
}
fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
let is_audio_result = object
.get("audioKind")
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
if !is_audio_result {
object.remove("provider");
}
if object
.get("model")
.and_then(Value::as_str)
.is_some_and(is_editor_internal_processing_model)
{
object.remove("model");
}
if let Some(generation_inputs) = object
.get_mut("generationInputs")
.and_then(Value::as_object_mut)
{
for field in ["screenColorHex", "mattingProvider", "mattingModel"] {
generation_inputs.remove(field);
}
}
object.remove("project");
object.remove("asset");
object.remove("spritesheetAsset");
for field in ["resource", "spritesheetResource"] {
let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else {
continue;
};
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
if let Some(icon_image_srcs) = object
.get_mut("iconImageSrcs")
.and_then(Value::as_array_mut)
{
for icon in icon_image_srcs {
let Some(icon) = icon.as_object_mut() else {
continue;
};
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
icon.retain(|key, _| {
matches!(
key.as_str(),
"name" | "imageSrc" | "width" | "height" | "resource"
)
});
}
}
result
}
fn compact_external_api_generation_result(result: Value) -> Value {
let mut result = result.get("data").cloned().unwrap_or(result);
let Some(object) = result.as_object_mut() else {