修复游戏智能体输入含空白片段时被误判为空 (#449)
Project CI / AI game creator shell Rust smoke (push) Successful in 2m20s
Project CI / AI game creator shell Rust crates (push) Successful in 1m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m51s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 5m21s
Project CI / Backend tests (push) Successful in 7m33s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

改为按整条消息判断有效内容,保留空白文本片段及两类引用的原有校验
补充多行文本、纯引用、全空白输入及非法引用的定向回归测试
同步素材引用文档、共享排障记录和过时测试注释

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/449
This commit was merged in pull request #449.
This commit is contained in:
2026-09-21 16:36:59 +08:00
parent c5afc02e94
commit 8520b50c81
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',
@@ -5867,3 +5867,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:① 依赖里只放数据,回调走 ref`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。
- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**renderer 工作集 **254 MB**(修复前 4.24.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
- **关联**`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx``apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`
## 2026-09-21 DirectProject 结构化消息不能逐个拒绝空白文本片段
- **现象**:多行正文、末尾空段落或合法引用前后的分隔空格会让有内容的消息报错;编辑器为了保持结构产生的空白文本片段被误判为“聊天内容为空”。
- **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。
- **处理(现行口径)**`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有合法 `agc_resource_reference` / `agc_runtime_region_reference` 时返回“聊天内容不能为空”。两类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。
- **关联**`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs``docs/【功能说明】AGC聊天素材引用-2026-09-08.md`
@@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个通过既有校验的 `agc_resource_reference` / `agc_runtime_region_reference`,否则返回“聊天内容不能为空”。两种引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。
## 拖拽引用(2026-09-21
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。