修复游戏智能体输入含空白片段时被误判为空
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
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user