修复 SFX V2 T1 验收问题

修复音频 compact 结果与 Agent reconcile 契约
统一正式 SFX 提交流程的 Unicode canonicalization
锁定队列 duration 与 Loop JSON 形态
收紧 Rust V2 metadata 构造和反序列化不变量
补齐共享边界、52 个预设 ID 和任意小数时长测试
This commit is contained in:
2026-08-06 14:07:05 +00:00
parent cd80c085ff
commit db9f452cae
12 changed files with 435 additions and 45 deletions
@@ -137,6 +137,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏
实施记录(`2026-08-06`):T1 已完成。前后端共享 canonicalization fixture 已锁定 Unicode 边界与 `2048 / 2049` 行为;52 个预设、最小优化 DTO、固定模型、nullable duration、Loop 默认值、响应结果字段和强类型 V2 metadata 已落地。duration 纯校验接受自动 `null` 与手动 `0.530s`,拒绝非有限值和越界值。External `model` 当前只落地纯 canonicalizer 与输入矩阵测试,正式定价、预扣、enqueue、OpenAPI 和副作用测试仍严格归属 T5;在 T5 完成前不得发布当前中间态。
补充验收(`2026-08-06`):音频 compact 结果保留完整 DTO 必填的 `provider`SFX / BGM 均通过真实 compact → Agent reconcile 回归;正式 SFX 提交流程不再执行原生 `trim()` 或默认 Prompt 回退。Rust V2 metadata 只能经校验构造并拒绝错误版本、模型、时长组合与实际时长;共享 fixture 直接锁定 `1 / 2048 / 2049`52 个预设 ID 和非 `0.1s` 步进小数时长均有固定断言。
### T2:一键优化 BFF 和 Worker 翻译 service
- 增加登录态 SFX Prompt 优化 BFF,固定 Luna + Medium + `max_output_tokens = 8192` completion 总预算,32 KiB body limit,严格唯一 JSON envelope,不调用音频 provider 或正式计费。
@@ -46,5 +46,30 @@
"input": "",
"prompt": "",
"charCount": 0
},
{
"name": "one-code-point-boundary",
"input": "声",
"prompt": "声",
"charCount": 1,
"validation": "valid"
},
{
"name": "exactly-2048-code-points",
"prefix": "\u0085",
"input": "声",
"suffix": "\u2003",
"prompt": "声",
"repeat": 2048,
"charCount": 2048,
"validation": "valid"
},
{
"name": "over-limit-2049-code-points",
"input": "声",
"prompt": "声",
"repeat": 2049,
"charCount": 2049,
"validation": "too-long"
}
]
@@ -11,3 +11,6 @@ pub use api::{
create_editor_agent_conversation, delete_editor_agent_conversation,
get_editor_agent_conversation, list_editor_agent_conversations,
};
#[cfg(test)]
pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test;
@@ -207,6 +207,15 @@ fn reconcile_completed_editor_agent_tool_call(
Ok(())
}
#[cfg(test)]
pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test(
message: &mut EditorAgentMessage,
result_payload_json: Option<&str>,
) -> Result<(), String> {
reconcile_completed_editor_agent_tool_call(message, result_payload_json)
.map_err(|error| format!("{error:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1554,8 +1554,9 @@ mod tests {
assert_eq!(job.price_mud_points, 5);
assert_eq!(
job.payload["duration"],
GenerateSoundEffectTool::DEFAULT_DURATION
json!(f64::from(GenerateSoundEffectTool::DEFAULT_DURATION))
);
assert_eq!(job.payload["loop"], json!(false));
assert_eq!(job.payload["projectId"], "project-1");
assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 7);
}
@@ -1251,9 +1251,15 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
// 紧凑结果会回传给普通用户的 Agent 工具调用卡片,不能把供应商或内部后处理实现
// 当作可见生成信息下发。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
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)
@@ -2069,6 +2075,84 @@ mod tests {
}
}
#[test]
fn editor_agent_audio_compact_results_remain_reconcileable() {
use crate::editor_agent::reconcile_completed_editor_agent_tool_call_for_test;
use platform_editor_agent::{
agent::tools::{
generate_background_music::GenerateBackgroundMusicTool,
generate_sound_effect::GenerateSoundEffectTool,
},
framework::tool::Tool,
};
use shared_contracts::editor_agent::{EditorAgentMessage, EditorAgentToolCallStatus};
for (tool_name, prompt, audio_kind) in [
(GenerateSoundEffectTool::NAME, "按钮点击声", "sound-effect"),
(
GenerateBackgroundMusicTool::NAME,
"森林背景音乐",
"background-music",
),
] {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
.to_string();
let response = json!({
"ok": true,
"audioSrc": "/generated/audio.mp3",
"width": 420,
"height": 120,
"sourceType": "generated",
"prompt": prompt,
"model": "audio1.0",
"provider": "vectorengine",
"taskId": "task-1",
"priceMudPoints": 5,
"audioKind": audio_kind,
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker compact payload should serialize");
assert_eq!(
payload["editor-agent-tool-call-result"]["provider"],
json!("vectorengine")
);
let mut message: EditorAgentMessage = serde_json::from_value(json!({
"id": 1,
"role": "system",
"text": "waiting",
"attachments": [],
"toolCall": {
"toolName": tool_name,
"status": "not_completed",
"args": { "prompt": prompt },
"displayArgs": {
"stringArgs": [],
"imageArgs": [],
"extras": { "priceMudPoints": 5 }
},
"externalJobId": "job-1",
"images": [],
"audios": []
},
"createdAt": "2026-08-06T00:00:00Z"
}))
.expect("pending audio Agent message should deserialize");
let payload_json = payload.to_string();
reconcile_completed_editor_agent_tool_call_for_test(
&mut message,
Some(payload_json.as_str()),
)
.expect("worker compact audio result should reconcile");
let tool_call = message.tool_call.expect("tool call should remain present");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
assert_eq!(tool_call.audios[0].audio_src, "/generated/audio.mp3");
}
}
#[test]
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
@@ -22,21 +22,29 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() {
let name = case["name"]
.as_str()
.expect("fixture name should be a string");
let input = case["input"]
let input_unit = case["input"]
.as_str()
.expect("fixture input should be a string");
let expected_prompt = case["prompt"]
let expected_prompt_unit = case["prompt"]
.as_str()
.expect("fixture prompt should be a string");
let repeat = case.get("repeat").and_then(Value::as_u64).unwrap_or(1) as usize;
let input = format!(
"{}{}{}",
case.get("prefix").and_then(Value::as_str).unwrap_or(""),
input_unit.repeat(repeat),
case.get("suffix").and_then(Value::as_str).unwrap_or("")
);
let expected_prompt = expected_prompt_unit.repeat(repeat);
let expected_char_count = case["charCount"]
.as_u64()
.expect("fixture charCount should be an unsigned integer")
as usize;
let canonical_prompt = canonicalize_sound_effect_prompt(input);
let canonical_prompt = canonicalize_sound_effect_prompt(&input);
assert_eq!(canonical_prompt, expected_prompt, "fixture={name}");
assert_eq!(
sound_effect_prompt_code_point_count(input),
sound_effect_prompt_code_point_count(&input),
expected_char_count,
"fixture={name}"
);
@@ -45,6 +53,17 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() {
canonical_prompt,
"canonicalization should be idempotent for fixture={name}"
);
match case.get("validation").and_then(Value::as_str) {
Some("valid") => {
validate_sound_effect_prompt(&input)
.unwrap_or_else(|error| panic!("fixture={name}: {error}"));
}
Some("too-long") => {
validate_sound_effect_prompt(&input)
.expect_err("shared over-limit fixture should be rejected");
}
_ => {}
}
}
}
@@ -89,6 +108,7 @@ fn sound_effect_duration_accepts_auto_and_frozen_boundaries() {
);
for duration_seconds in [
SOUND_EFFECT_DURATION_MIN_SECONDS,
1.23456789,
7.5,
SOUND_EFFECT_DURATION_MAX_SECONDS,
] {
+173 -20
View File
@@ -522,18 +522,128 @@ pub enum EditorSoundEffectDurationMode {
Manual,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerationMetadataV2 {
pub schema_version: u8,
pub user_prompt: String,
pub actual_prompt: String,
pub model: String,
pub duration_mode: EditorSoundEffectDurationMode,
pub requested_duration_seconds: Option<f64>,
pub actual_duration_seconds: f64,
schema_version: u8,
user_prompt: String,
actual_prompt: String,
model: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
#[serde(rename = "loop")]
pub loop_enabled: bool,
loop_enabled: bool,
}
impl EditorSoundEffectGenerationMetadataV2 {
pub fn try_new(
user_prompt: String,
actual_prompt: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
loop_enabled: bool,
) -> Result<Self, &'static str> {
let requested_duration_is_valid = match duration_mode {
EditorSoundEffectDurationMode::Auto => requested_duration_seconds.is_none(),
EditorSoundEffectDurationMode::Manual => requested_duration_seconds
.is_some_and(|duration| duration.is_finite() && (0.5..=30.0).contains(&duration)),
};
if !requested_duration_is_valid {
return Err("SFX V2 metadata 的时长模式与请求时长不一致");
}
if !actual_duration_seconds.is_finite()
|| actual_duration_seconds <= 0.0
|| actual_duration_seconds > 600.0
{
return Err("SFX V2 metadata 的实际时长无效");
}
Ok(Self {
schema_version: 2,
user_prompt,
actual_prompt,
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
duration_mode,
requested_duration_seconds,
actual_duration_seconds,
loop_enabled,
})
}
pub const fn schema_version(&self) -> u8 {
self.schema_version
}
pub fn user_prompt(&self) -> &str {
self.user_prompt.as_str()
}
pub fn actual_prompt(&self) -> &str {
self.actual_prompt.as_str()
}
pub fn model(&self) -> &str {
self.model.as_str()
}
pub const fn duration_mode(&self) -> EditorSoundEffectDurationMode {
self.duration_mode
}
pub const fn requested_duration_seconds(&self) -> Option<f64> {
self.requested_duration_seconds
}
pub const fn actual_duration_seconds(&self) -> f64 {
self.actual_duration_seconds
}
pub const fn loop_enabled(&self) -> bool {
self.loop_enabled
}
}
impl<'de> Deserialize<'de> for EditorSoundEffectGenerationMetadataV2 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
schema_version: u8,
user_prompt: String,
actual_prompt: String,
model: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
#[serde(rename = "loop")]
loop_enabled: bool,
}
let payload = Payload::deserialize(deserializer)?;
if payload.schema_version != 2 {
return Err(D::Error::custom("SFX metadata 只支持 schemaVersion 2"));
}
if payload.model != EDITOR_SOUND_EFFECT_MODEL {
return Err(D::Error::custom(format!(
"SFX V2 metadata 只支持模型 {EDITOR_SOUND_EFFECT_MODEL}"
)));
}
Self::try_new(
payload.user_prompt,
payload.actual_prompt,
payload.duration_mode,
payload.requested_duration_seconds,
payload.actual_duration_seconds,
payload.loop_enabled,
)
.map_err(D::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -1559,16 +1669,29 @@ mod tests {
#[test]
fn editor_sound_effect_v2_metadata_uses_the_frozen_camel_case_shape() {
let metadata = EditorSoundEffectGenerationMetadataV2 {
schema_version: 2,
user_prompt: "金币落地,轻快可爱".to_string(),
actual_prompt: "A bright, cute coin landing chime".to_string(),
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
duration_mode: EditorSoundEffectDurationMode::Manual,
requested_duration_seconds: Some(5.0),
actual_duration_seconds: 5.12,
loop_enabled: false,
};
let metadata = EditorSoundEffectGenerationMetadataV2::try_new(
"金币落地,轻快可爱".to_string(),
"A bright, cute coin landing chime".to_string(),
EditorSoundEffectDurationMode::Manual,
Some(5.0),
5.12,
false,
)
.expect("valid SFX V2 metadata should construct");
assert_eq!(metadata.schema_version(), 2);
assert_eq!(metadata.model(), EDITOR_SOUND_EFFECT_MODEL);
assert_eq!(metadata.user_prompt(), "金币落地,轻快可爱");
assert_eq!(
metadata.actual_prompt(),
"A bright, cute coin landing chime"
);
assert_eq!(
metadata.duration_mode(),
EditorSoundEffectDurationMode::Manual
);
assert_eq!(metadata.requested_duration_seconds(), Some(5.0));
assert_eq!(metadata.actual_duration_seconds(), 5.12);
assert!(!metadata.loop_enabled());
let payload = serde_json::to_value(&metadata).expect("metadata should serialize");
assert_eq!(
payload,
@@ -1584,10 +1707,40 @@ mod tests {
})
);
assert_eq!(
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload)
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload.clone())
.expect("metadata should deserialize"),
metadata
);
for invalid in [
json!({ "schemaVersion": 1 }),
json!({ "model": "audio1.0" }),
json!({
"durationMode": "auto",
"requestedDurationSeconds": 5.0,
}),
json!({
"durationMode": "manual",
"requestedDurationSeconds": null,
}),
json!({ "actualDurationSeconds": 600.1 }),
] {
let mut invalid_payload = payload.clone();
invalid_payload
.as_object_mut()
.expect("metadata payload should be an object")
.extend(
invalid
.as_object()
.expect("invalid override should be an object")
.clone(),
);
assert!(
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(invalid_payload)
.is_err(),
"invalid metadata override should be rejected: {invalid}"
);
}
}
#[test]
@@ -22,6 +22,60 @@ describe('ImageCanvasSoundEffectPresetModel', () => {
expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe(
52,
);
expect(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).toEqual([
'button-tap',
'operation-confirm',
'back-cancel',
'page-transition',
'notification',
'operation-error',
'coin-pickup',
'item-pickup',
'reward-received',
'chest-open',
'content-unlock',
'rare-drop',
'item-craft',
'character-level-up',
'quest-complete',
'achievement-unlocked',
'challenge-victory',
'challenge-defeat',
'character-jump',
'character-land',
'light-hit',
'heavy-hit',
'attack-swing',
'attack-hit',
'block-success',
'object-break',
'skill-charge',
'magic-cast',
'healing',
'shield-create',
'teleport',
'ice-skill',
'fire-skill',
'status-buff',
'door-open',
'mechanism-start',
'lever-trigger',
'stone-move',
'portal-open',
'countdown-warning',
'casual-cute',
'retro-arcade',
'sci-fi-electronic',
'fantasy-magic',
'realistic-natural',
'cartoon-exaggerated',
'gentle-feedback',
'strong-feedback',
'short-feedback',
'two-stage-rise',
'clean-prominent',
'soft-not-harsh',
]);
expect(
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size,
).toBe(52);
@@ -11,19 +11,29 @@ import {
describe('ImageCanvasSoundEffectPromptModel', () => {
it('matches the shared Unicode canonicalization fixtures', () => {
for (const fixture of soundEffectPromptCanonicalizationCases) {
expect(canonicalizeSoundEffectPrompt(fixture.input), fixture.name).toBe(
fixture.prompt,
const repeat = fixture.repeat ?? 1;
const input = `${fixture.prefix ?? ''}${fixture.input.repeat(repeat)}${fixture.suffix ?? ''}`;
const expectedPrompt = fixture.prompt.repeat(repeat);
expect(canonicalizeSoundEffectPrompt(input), fixture.name).toBe(
expectedPrompt,
);
expect(countSoundEffectPromptCodePoints(input), fixture.name).toBe(
fixture.charCount,
);
expect(
countSoundEffectPromptCodePoints(fixture.input),
canonicalizeSoundEffectPrompt(canonicalizeSoundEffectPrompt(input)),
fixture.name,
).toBe(fixture.charCount);
expect(
canonicalizeSoundEffectPrompt(
canonicalizeSoundEffectPrompt(fixture.input),
),
fixture.name,
).toBe(fixture.prompt);
).toBe(expectedPrompt);
if (fixture.validation === 'valid') {
expect(validateSoundEffectPrompt(input).ok, fixture.name).toBe(true);
} else if (fixture.validation === 'too-long') {
expect(validateSoundEffectPrompt(input), fixture.name).toEqual({
ok: false,
prompt: expectedPrompt,
charCount: fixture.charCount,
reason: 'too-long',
});
}
}
});
@@ -1898,7 +1898,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
initialDialog={{
id: 'dialog-sound',
mode: 'audio-sound-effect',
prompt: ' 金币掉落叮当声 ',
prompt: '\u0085\uFEFF金币掉落叮当声\uFEFF\u0085',
status: 'idle',
composerOpen: true,
soundDurationSeconds: 7,
@@ -1920,7 +1920,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '金币掉落叮当声',
prompt: '\uFEFF金币掉落叮当声\uFEFF',
duration: 7,
projectId: 'editor-project-audio',
assetFolderId: 'project',
@@ -1953,6 +1953,33 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('rejects an empty canonical sound effect prompt without a fallback', async () => {
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-sound-empty',
mode: 'audio-sound-effect',
prompt: '\u0085 \n\t\u0085',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-sound-effect::failed:open:音效描述不能为空',
);
});
expect(screen.getByTestId('tracked-dialog').textContent).not.toContain(
'游戏音效',
);
});
it('claims and canonicalizes one background music submission before the first await', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
@@ -83,6 +83,7 @@ import {
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
} from './ImageCanvasGenerationSubmissionModel';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
@@ -1882,14 +1883,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
currentBackgroundMusicSubmissionScopeRef.current.version,
}
: null;
const soundEffectPrompt =
dialog.mode === 'audio-sound-effect'
? validateSoundEffectPrompt(dialog.prompt)
: null;
const normalizedPrompt =
backgroundMusicSubmission?.prompt ??
soundEffectPrompt?.prompt ??
(dialog.prompt.trim() ||
(dialog.mode === 'edit'
? '修改当前图片'
: dialog.mode === 'audio-sound-effect'
? '游戏音效'
: 'AI 生成图片'));
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'));
if (!backgroundMusicSubmission && canvasDialog) {
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
...currentDialog,