diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 35c52a8bc..bc5d088a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -839,7 +839,7 @@ fn direct_thread_visible_item( /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + /// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; /// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 -fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { +pub(super) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { let client_turn_id = client_turn_id.trim(); (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) } @@ -1619,6 +1619,36 @@ async fn stage_codex_app_server_image( Ok(path) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexTurnKind { + User, + HostFeedback, +} + +async fn direct_project_turn_input( + request: &LlmRunRequest, + prompt: &str, + workspace_path: &std::path::Path, + turn_kind: DirectCodexTurnKind, + original_user_item: Option<&serde_json::Value>, + skill_roots: &[std::path::PathBuf], +) -> Result { + // 原始用户条目一直用于历史和事件关联,但只有首次请求将它作为模型输入。 + if turn_kind == DirectCodexTurnKind::User { + if let Some(item) = original_user_item { + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; + return direct_codex_user_item_to_codex_turn_input( + workspace_path, + &canonical, + skill_roots, + ) + .map_err(platform_llm::LlmError::InvalidRequest); + } + } + codex_app_server_turn_input(request, prompt, workspace_path).await +} + async fn codex_app_server_turn_input( request: &LlmRunRequest, prompt: &str, @@ -3217,6 +3247,7 @@ impl CodexAppServerConnection { None, None, None, + DirectCodexTurnKind::User, on_agent_message_delta, direct_observer, audit, @@ -3233,6 +3264,7 @@ impl CodexAppServerConnection { direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, direct_user_item: Option<&serde_json::Value>, + turn_kind: DirectCodexTurnKind, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, @@ -3358,18 +3390,15 @@ impl CodexAppServerConnection { .map_err(platform_llm::LlmError::InvalidRequest)? }; let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(item) = direct_user_item { - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) - .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; - direct_codex_user_item_to_codex_turn_input( - &self.inner.workspace_path, - &canonical, - self.inner._skill_roots.as_deref().unwrap_or_default(), - ) - .map_err(platform_llm::LlmError::InvalidRequest)? - } else { - codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? - } + direct_project_turn_input( + &request, + &prompt, + &self.inner.workspace_path, + turn_kind, + direct_user_item, + self.inner._skill_roots.as_deref().unwrap_or_default(), + ) + .await? } else { codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? }; @@ -5042,6 +5071,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root, system_prompt, user_prompt, + DirectCodexTurnKind::User, None, None, None, @@ -5060,6 +5090,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( root, system_prompt, user_prompt, + DirectCodexTurnKind::User, None, Some(observer), None, @@ -5072,6 +5103,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, + turn_kind: DirectCodexTurnKind, client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, @@ -5171,6 +5203,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( Some(&codex_root), effective_client_turn_id, direct_user_item.as_ref(), + turn_kind, None, observer, audit, @@ -5348,6 +5381,7 @@ mod tests { Path::new("not-read"), String::new(), String::new(), + DirectCodexTurnKind::User, Some("not-executed"), None, None, @@ -6018,6 +6052,108 @@ mod tests { } } + #[tokio::test] + async fn direct_project_turn_input_preserves_user_input_and_sends_host_feedback() { + let temp = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(temp.path(), "feedback-input", "反馈输入") + .expect("init project"); + let skill_root = temp.path().join("skills"); + let skill_path = skill_root.join("test-skill").join("SKILL.md"); + std::fs::create_dir_all(skill_path.parent().unwrap()).expect("create skill directory"); + std::fs::write(&skill_path, "# 测试 Skill").expect("write skill"); + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:feedback-input:user", + "content": [ + {"type": "input_text", "text": "请创建菜单"}, + {"type": "agc_skill_reference", "name": "test-skill"} + ] + }); + let skill_roots = vec![skill_root]; + let request = LlmRunRequest::single_turn("系统", "请创建菜单"); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + DirectCodexTurnKind::User, + Some(&user_item), + &skill_roots, + ) + .await + .expect("initial input"); + assert_eq!( + input, + serde_json::json!([ + {"type": "text", "text": "请创建菜单"}, + {"type": "skill", "name": "test-skill", "path": skill_path} + ]) + ); + + // 连续验收反馈和执行错误都携带同一个原始条目,但 wire input 必须是当次反馈。 + for feedback in [ + "宿主验收未通过:缺少移动端玩法证据,请补齐。", + "npm run build 失败:入口不存在,请修复后继续。", + ] { + let request = LlmRunRequest::single_turn("系统", feedback); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + DirectCodexTurnKind::HostFeedback, + Some(&user_item), + &skill_roots, + ) + .await + .expect("host feedback input"); + assert_eq!( + input, + serde_json::json!([{"type": "text", "text": feedback}]) + ); + } + } + + #[tokio::test] + async fn direct_project_turn_input_cli_feedback_keeps_one_original_history_item() { + let temp = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(temp.path(), "cli-feedback", "CLI 反馈") + .expect("init project"); + let user_item = direct_project_local_message_item( + "user", + "请创建菜单", + direct_codex_user_item_id_for_client_turn_id("direct-cli-feedback").as_deref(), + ) + .expect("freeze original CLI input"); + for (kind, prompt) in [ + (DirectCodexTurnKind::User, "请创建菜单"), + ( + DirectCodexTurnKind::HostFeedback, + "宿主验收未通过:缺少玩法证据", + ), + (DirectCodexTurnKind::HostFeedback, "构建失败:请修复入口"), + ] { + // 与发送入口一样,每次先幂等落盘原始用户条目,再构造本次模型输入。 + append_direct_project_user_message_at(temp.path(), &user_item) + .expect("persist original user item without an ID conflict"); + let request = LlmRunRequest::single_turn("系统", prompt); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + kind, + Some(&user_item), + &[], + ) + .await + .expect("CLI turn input"); + assert_eq!(input, serde_json::json!([{"type": "text", "text": prompt}])); + } + assert_eq!( + read_direct_project_history_items_at(temp.path()).expect("read history"), + vec![user_item] + ); + } + #[tokio::test] async fn app_server_turn_input_stages_data_url_as_isolated_local_image() { let temp = tempfile::tempdir().expect("temp dir"); @@ -6035,9 +6171,16 @@ mod tests { let prompt = codex_app_server_text_prompt(&request).expect("sanitized prompt"); assert!(!prompt.contains("data:image")); assert!(prompt.contains("原生视觉输入")); - let input = codex_app_server_turn_input(&request, &prompt, temp.path()) - .await - .expect("turn input"); + let input = direct_project_turn_input( + &request, + &prompt, + temp.path(), + DirectCodexTurnKind::User, + None, + &[], + ) + .await + .expect("turn input"); assert_eq!(input[0]["type"], "text"); assert_eq!(input[1]["type"], "localImage"); let staged_path = input[1]["path"].as_str().expect("staged path"); @@ -7717,6 +7860,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), + DirectCodexTurnKind::User, None, Some(&mut observer), None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index e4c3d182e..4db66f9f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5115,6 +5115,20 @@ async fn run_direct_game_creator_turn_inner( if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } + // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID + // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 + let direct_user_item = match direct_user_item { + Some(item) => item, + None => execution_session.snapshot().and_then(|state| { + direct_project_local_message_item( + "user", + prompt, + direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(), + ) + }).map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?, + }; if execution_session.newly_accepted { if let Ok(ledger) = execution_session.snapshot() { crate::analytics::goal::accepted( @@ -5268,20 +5282,23 @@ async fn run_direct_game_creator_turn_inner( } }; let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; let mut audit = audit; let mut attempt = 1; let reply_result = loop { - match direct_game_creator_codex_chat_at_with_optional_observer( + let result = direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt.clone(), feedback_prompt.clone(), + turn_kind, Some(&client_turn_id), Some(&mut observer), audit.as_deref_mut(), - direct_user_item.clone(), + Some(direct_user_item.clone()), ) - .await - { + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { Ok(value) => match super::direct_delivery::review_reply(root,&execution_session).await { Ok(Some(report)) => break Ok(report), Ok(None) => break Ok(value), @@ -5324,21 +5341,24 @@ async fn run_direct_game_creator_turn_inner( reply_result } else { let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; let mut audit = audit; let mut response = None; let mut attempt = 1; loop { - match direct_game_creator_codex_chat_at_with_optional_observer( + let result = direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt.clone(), feedback_prompt.clone(), + turn_kind, None, None, audit.as_deref_mut(), - direct_user_item.clone(), + Some(direct_user_item.clone()), ) - .await - { + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { Ok(value) => { match super::direct_delivery::review_reply(root,&execution_session).await { Ok(Some(report)) => { response = Some(report); break; } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d46135301..3ea343d3d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## Direct 宿主继续请求不能重发原始用户条目 + +原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 + ## 策划 Agent 提示词中的相对路径不要当作内部实现删去 `project/...` 是 Agent 读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径,具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 147f98fe0..25451c57d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,12 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-23 Direct 宿主继续请求输入修复 + +- 首次模型请求使用原始结构化用户输入,保留 Skill 提及及其它引用;未提供结构化输入时沿用请求正文与图片转换。 +- 验收反馈和可修复错误触发的后续模型请求,显式标记为宿主继续,只发送本次反馈正文,不再用原始用户条目覆盖。GUI 与 CLI 共用这一规则,验收条件、重试上限和执行预算保持不变。 +- 原始用户条目仍用于历史持久化和回合事件关联,沿用同一 clientTurnId/itemId;反馈不是新的用户输入。不要通过丢弃原始条目来修复发送内容。 +- 定向回归检查首次结构化输入、验收继续和错误继续的实际 wire input,以及同一用户条目的历史去重。 + ## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。