修复游戏智能体输入含空白片段时被误判为空
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

改为按整条消息判断有效内容,保留空白文本片段及两类引用的原有校验
补充多行文本、纯引用、全空白输入及非法引用的定向回归测试
同步素材引用文档、共享排障记录和过时测试注释
This commit is contained in:
2026-09-21 16:23:36 +08:00
parent f4ebc45612
commit a4d5fcb5fc
5 changed files with 148 additions and 9 deletions
@@ -21,30 +21,36 @@ pub(crate) fn validate_direct_codex_user_item(
return Err("DirectProject user item 缺少稳定 id".to_string());
}
if message.content.is_empty() {
return Err("DirectProject user item content 不能为空".to_string());
return Err("聊天内容不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
let mut has_effective_content = false;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
if !text.trim().is_empty() {
has_effective_content = true;
}
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
has_effective_content = true;
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
has_effective_content = true;
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
if !has_effective_content {
return Err("聊天内容不能为空".to_string());
}
Ok(())
}
@@ -197,7 +197,10 @@ fn render_ui_design_code_context(
#[cfg(test)]
mod tests {
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
use super::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
validate_direct_codex_user_item,
};
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
use serde_json::json;
use shared_contracts::game_creation_app::{
@@ -295,7 +298,7 @@ mod tests {
}
#[test]
fn response_item_projection_uses_input_text_not_turn_input_text() {
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
@@ -303,12 +306,132 @@ mod tests {
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [{"type": "input_text", "text": "你好"}]
"content": [
{"type": "input_text", "text": ""},
{"type": "input_text", "text": "你好"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": "第二段"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
assert_eq!(projected["content"][0]["type"], "input_text");
assert_ne!(projected["content"][0]["type"], "text");
assert_eq!(projected["content"], item["content"]);
let canonical = serde_json::from_value(item).expect("canonical user item");
assert_eq!(
direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"),
"你好\n第二段\n "
);
}
#[test]
fn user_input_rejects_empty_or_whitespace_only_messages() {
let root = prompt_context_project();
for content in [
json!([]),
json!([{"type": "input_text", "text": ""}]),
json!([
{"type": "input_text", "text": ""},
{"type": "input_text", "text": " \t\r\n\u{3000}"}
]),
] {
let item = serde_json::from_value(json!({
"type": "message", "role": "user", "id": "turn-1:user", "content": content
}))
.expect("canonical user item");
assert_eq!(
validate_direct_codex_user_item(root.path(), &item),
Err("聊天内容不能为空".to_string())
);
}
}
#[test]
fn valid_references_allow_missing_text_and_whitespace_parts() {
let root = prompt_context_project();
let asset_id = register_fixture_asset(
root.path(),
"assets/hero.png",
GameCreationAppAssetKind::Character,
"image/png",
);
for (reference, expected_text) in [
(
json!({"type": "agc_resource_reference", "resourceId": asset_id}),
format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"),
),
(
json!({"type": "agc_runtime_region_reference", "label": "主画面"}),
"[运行画面区域:名称=主画面 ]".to_string(),
),
] {
for (content, expected_prompt) in [
(json!([reference.clone()]), expected_text.clone()),
(
json!([
{"type": "input_text", "text": ""},
{"type": "input_text", "text": "\n"},
reference,
{"type": "input_text", "text": " "}
]),
format!("\n{expected_text} "),
),
] {
let item = json!({
"type": "message", "role": "user", "id": "turn-1:user", "content": content
});
let canonical = serde_json::from_value(item.clone()).expect("canonical user item");
assert_eq!(
direct_codex_user_item_to_prompt(root.path(), &canonical)
.expect("reference prompt"),
expected_prompt
);
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("reference history projection");
assert_eq!(
projected["content"].as_array().unwrap().len(),
content.as_array().unwrap().len()
);
}
}
}
#[test]
fn nonempty_text_does_not_bypass_invalid_reference_validation() {
let root = prompt_context_project();
for (reference, expected_error) in [
(
json!({"type": "agc_resource_reference", "resourceId": " "}),
"引用的素材 ID 无效,请移除后重新选择",
),
(
json!({"type": "agc_resource_reference", "resourceId": "missing"}),
"引用的素材已不存在,请移除后重新选择",
),
(
json!({"type": "agc_runtime_region_reference", "label": " "}),
"运行画面区域缺少名称",
),
(
json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}),
"引用的素材已不存在,请移除后重新选择",
),
] {
let item = serde_json::from_value(json!({
"type": "message", "role": "user", "id": "turn-1:user",
"content": [
{"type": "input_text", "text": "有真实文字"},
reference,
{"type": "input_text", "text": " "}
]
}))
.expect("canonical user item");
assert_eq!(
validate_direct_codex_user_item(root.path(), &item),
Err(expected_error.to_string())
);
}
}
#[test]
@@ -639,7 +639,8 @@ describe('ResourceReferenceInput', () => {
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。
// 草稿 text 投影会 trim,但提交用的 content 仍保留引用节点后的分隔空格;
// 结构化引用完整保留,后端按整条消息判断是否有有效内容。
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero');
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe(
'hero',