From 9ce0e89e4355b9b4b8604295206f6fc681929bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:02:26 +0800 Subject: [PATCH 01/32] =?UTF-8?q?=E8=A1=A5=E5=85=85=20DirectProject=20Code?= =?UTF-8?q?x=20=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DirectProject 原始 Responses item 历史格式与异常收尾规则 明确 thread/inject_items 恢复流程及半成品处理边界 不涉及旧格式迁移、DirectHome 或审计账本 --- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md new file mode 100644 index 000000000..58f0537b8 --- /dev/null +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -0,0 +1,50 @@ +# DirectProject Codex 原始历史与异常恢复 + +更新时间:`2026-09-04` + +## 目标 + +DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。 + +本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。 + +## 文件格式 + +每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段: + +```json +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} +``` + +`payload` 必须是未经改写的 Responses item。用户 message 在 `turn/start` 前追加;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 + +DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 + +## 正常回合 + +1. 将本轮用户文本构造成 Responses `message` item 并追加、flush。 +2. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 +3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 +4. 正常 `turn/completed: completed` 不生成额外记录。 + +## 异常回合收尾 + +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。 + +`item/agentMessage/delta` 带有 `itemId`。AGC 在内存中按 `itemId` 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: + +```json +{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}} +``` + +合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。 + +## 恢复 + +创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。 + +聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 + +## 写入与损坏边界 + +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。 -- 2.52.0 From 3d44af0a7d0292c2cf40976668920c9d27cbc591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:17:35 +0800 Subject: [PATCH 02/32] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20DirectProject=20?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Codex Responses item JSONL 读写模块并启用 raw 事件 DirectProject 新线程使用 thread/inject_items 恢复历史 异常终态收尾累计 assistant 文本并移除旧文本 replay 写入 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 105 ++++++++++---- .../src/agent/direct_project_history.rs | 135 ++++++++++++++++++ .../src-tauri/src/agent/direct_runtime.rs | 60 -------- .../src-tauri/src/project/agent_db.rs | 10 +- 5 files changed, 221 insertions(+), 91 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index bb0f70aee..526857c4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,7 @@ mod codex_cli; mod codex_provider_proxy; mod direct_codex_attachments; mod direct_codex_audit; +mod direct_project_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -38,6 +39,7 @@ pub(crate) use codex_cli::{ pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; +pub(crate) use direct_project_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 84504d2d8..692ac87bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -374,12 +374,16 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { - AgentMessageDelta(String), + AgentMessageDelta { + item_id: String, + delta: String, + }, Activity(&'static str), Item { completed: bool, params: serde_json::Value, }, + RawItem(serde_json::Value), Terminal(serde_json::Value), TransportClosed(String), } @@ -961,6 +965,9 @@ fn codex_app_server_thread_start_params( params["modelProvider"] = serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + params["experimentalRawEvents"] = serde_json::Value::Bool(true); + } params } @@ -2114,7 +2121,7 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, - direct_client_turn_id: Option<&str>, + _direct_client_turn_id: Option<&str>, 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>, @@ -2124,29 +2131,29 @@ impl CodexAppServerConnection { self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); let mut request = request; - if thread_created && self.inner.workspace_mode.uses_direct_conversation() { - // DirectProject owns the append-only project history in AGC. The - // replay builder derives a bounded prompt without mutating that - // durable fact source, so a new ephemeral thread can recover the - // newest contiguous context within the model budget. - let current_prompt = direct_codex_current_user_prompt(&request).to_string(); - let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - let history_prompt = build_direct_codex_history_prompt( + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let current_prompt = direct_codex_current_user_prompt(&request).trim(); + if current_prompt.is_empty() { + return Err(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + )); + } + if thread_created { + let items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + self.request( + "thread/inject_items", + serde_json::json!({"threadId": thread_id, "items": items}), + ) + .await + .map_err(platform_llm::LlmError::Transport)?; + } + append_direct_project_history_item_at( history_root, - direct_client_turn_id.unwrap_or("__none__"), - ¤t_prompt, - &request, - llm, + &direct_project_user_message_item(current_prompt), ) .map_err(platform_llm::LlmError::InvalidRequest)?; - if let Some(message) = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - { - message.content = history_prompt; - } } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) @@ -2228,6 +2235,7 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let mut receiver = self.register_turn(&turn_id).await; + let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { connection: self.clone(), thread_id: thread_id.clone(), @@ -2274,7 +2282,8 @@ impl CodexAppServerConnection { } }; match event { - Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { + direct_project_history.observe_delta(&item_id, &delta); streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::AccumulatedText( @@ -2289,6 +2298,15 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::RawItem(item)) => { + if self.inner.workspace_mode + == CodexAppServerWorkspaceMode::DirectProject + { + append_direct_project_history_item_at(history_root, &item) + .map_err(platform_llm::LlmError::Transport)?; + direct_project_history.complete_item(&item); + } + } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity(activity)); @@ -2396,7 +2414,22 @@ impl CodexAppServerConnection { } } }; - let text = collect.await?; + let text = match collect.await { + Ok(text) => text, + Err(error) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Err(persist_error) = persist_direct_project_partial_items_at( + history_root, + &mut direct_project_history, + ) { + return Err(platform_llm::LlmError::Transport(format!( + "DirectProject 异常收尾历史失败:{persist_error};原始回合错误:{error}" + ))); + } + } + return Err(error); + } + }; guard.armed = false; self.inner.turns.lock().await.remove(&turn_id); let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; @@ -2764,7 +2797,11 @@ async fn read_game_creator_codex_app_server_stdout( let safe_activity = direct_codex_safe_activity_for_notification(method); if !matches!( method, - "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + "item/agentMessage/delta" + | "item/started" + | "item/completed" + | "rawResponseItem/completed" + | "turn/completed" ) && safe_activity.is_none() { continue; @@ -2805,12 +2842,28 @@ async fn read_game_creator_codex_app_server_stdout( else { continue; }; - CodexTurnEvent::AgentMessageDelta(delta.to_string()) + let Some(item_id) = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + CodexTurnEvent::AgentMessageDelta { + item_id: item_id.to_string(), + delta: delta.to_string(), + } } "item/started" | "item/completed" => CodexTurnEvent::Item { completed: method == "item/completed", params, }, + "rawResponseItem/completed" => CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + ), _ => CodexTurnEvent::Terminal(params), } }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs new file mode 100644 index 000000000..f4c19615a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -0,0 +1,135 @@ +use super::*; +use crate::project::{append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader}; + +const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; + +#[derive(Default)] +pub(crate) struct DirectProjectHistoryAccumulator { + text_by_item_id: BTreeMap, +} + +impl DirectProjectHistoryAccumulator { + pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { + self.text_by_item_id + .entry(item_id.to_string()) + .or_default() + .push_str(delta); + } + + pub(crate) fn complete_item(&mut self, item: &Value) { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + self.text_by_item_id.remove(item_id); + } + } + + pub(crate) fn take_partial_items(&mut self) -> Vec { + std::mem::take(&mut self.text_by_item_id) + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(item_id, text)| { + serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }) + }) + .collect() + } +} + +pub(crate) fn persist_direct_project_partial_items_at( + root: &Path, + accumulator: &mut DirectProjectHistoryAccumulator, +) -> Result<(), String> { + for item in accumulator.take_partial_items() { + append_direct_project_history_item_at(root, &item)?; + } + Ok(()) +} + +fn history_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/project.jsonl") +} + +fn record(item: &Value) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) +} + +pub(crate) fn append_direct_project_history_item_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; + let path = history_path(root); + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("DirectProject 历史追加写")?; + let line = record(item)?; + append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") +} + +pub(crate) fn direct_project_user_message_item(prompt: &str) -> Value { + serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": prompt}], + }) +} + +pub(crate) fn read_direct_project_history_items_at( + root: &Path, +) -> Result, String> { + let path = history_path(root); + if !path.exists() { + return Ok(Vec::new()); + } + let file = File::open(&path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut items = Vec::new(); + let mut reader = BufReader::new(file); + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + if bytes == 0 { + break; + } + let had_newline = line.ends_with('\n'); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(_error) if !had_newline => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )); + } + }; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) + { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + items.push(item); + } + Ok(items) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index fc36389d8..2098314b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4171,48 +4171,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "assistant".to_string(), - content: reply.to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:assistant"), - ) - .map(|_| ()) -} - -fn persist_direct_codex_user_prompt_at( - root: &Path, - client_turn_id: &str, - prompt: &str, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "user".to_string(), - content: prompt.trim().to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:user"), - ) - .map(|_| ()) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4244,15 +4202,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), - 500, - )); - } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4268,15 +4217,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"), - 500, - )); - } audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone())); Ok(reply) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index bd2f3e18b..4fd654a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) } -pub(super) struct ProjectAppendLock { +pub(crate) struct ProjectAppendLock { process_lock: Arc>, os_lock_path: PathBuf, } -pub(super) struct ProjectAppendGuard<'a> { +pub(crate) struct ProjectAppendGuard<'a> { _process_guard: std::sync::MutexGuard<'a, ()>, _os_lock: File, } @@ -4335,7 +4335,7 @@ impl ProjectAppendLock { .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) } - pub(super) fn lock(&self, error_label: &str) -> Result, String> { + pub(crate) fn lock(&self, error_label: &str) -> Result, String> { let process_guard = self .process_lock .lock() @@ -4348,7 +4348,7 @@ impl ProjectAppendLock { } } -pub(super) fn project_append_lock_for(path: &Path) -> Result { +pub(crate) fn project_append_lock_for(path: &Path) -> Result { let mut locks = project_append_locks() .lock() .map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?; @@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result Date: Fri, 4 Sep 2026 17:20:33 +0800 Subject: [PATCH 03/32] =?UTF-8?q?=E8=AE=A9=E9=A1=B9=E7=9B=AE=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=AF=BB=E5=8F=96=E5=8E=9F=E5=A7=8B=20item=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProject 聊天命令写入 Responses message item 聊天读取从原始 item 投影 user/assistant 内容 保留 Agent session 旧会话读写路径 --- .../src/agent/direct_project_history.rs | 75 +++++++++++++++++++ .../src-tauri/src/commands.rs | 13 ++++ 2 files changed, 88 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index f4c19615a..31cf94aba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -72,10 +72,41 @@ pub(crate) fn append_direct_project_history_item_at( let path = history_path(root); let lock = project_append_lock_for(&path)?; let _append_guard = lock.lock("DirectProject 历史追加写")?; + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + if read_direct_project_history_items_at(root)?.iter().any(|existing| { + existing.get("id").and_then(Value::as_str) == Some(item_id) + }) { + return Ok(()); + } + } let line = record(item)?; append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } +pub(crate) fn direct_project_local_message_item( + role: &str, + content: &str, + message_id: Option<&str>, +) -> Result { + let role = role.trim(); + let content = content.trim(); + if content.is_empty() || !matches!(role, "user" | "assistant") { + return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string()); + } + let mut item = serde_json::json!({ + "type": "message", + "role": role, + "content": [{ + "type": if role == "user" { "input_text" } else { "output_text" }, + "text": content, + }], + }); + if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) { + item["id"] = Value::String(message_id.to_string()); + } + Ok(item) +} + pub(crate) fn direct_project_user_message_item(prompt: &str) -> Value { serde_json::json!({ "type": "message", @@ -133,3 +164,47 @@ pub(crate) fn read_direct_project_history_items_at( } Ok(items) } + +pub(crate) fn read_direct_project_chat_history_at( + root: &Path, +) -> Result { + let path = history_path(root); + let items = read_direct_project_history_items_at(root)?; + let messages = items + .into_iter() + .filter_map(|item| { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + let content = item + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default(); + (!content.is_empty()).then(|| LocalConversationMessageRecord { + schema_version: "agc-direct-project-context.v1".to_string(), + role: role.to_string(), + content, + agent_id: None, + message_id: item + .get("id") + .and_then(Value::as_str) + .map(str::to_string), + updated_at: 0, + }) + }) + .collect(); + Ok(LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id: None, + session_id: None, + messages, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 99eeb1fa9..aef229a17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::read_direct_project_chat_history_at; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -4707,6 +4708,9 @@ pub(crate) fn read_local_conversation( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; + if agent_id.is_none() && session_id.is_none() { + return read_direct_project_chat_history_at(root); + } read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } @@ -4720,6 +4724,15 @@ pub(crate) fn append_local_conversation_message( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; + if agent_id.is_none() && session_id.is_none() { + let item = direct_project_local_message_item( + &message.role, + &message.content, + message_id.as_deref(), + )?; + append_direct_project_history_item_at(root, &item)?; + return read_direct_project_chat_history_at(root); + } let _lock = acquire_project_write_lock(root, "conversation.write")?; match message_id.as_deref() { Some(message_id) => append_local_conversation_message_for_session_idempotent_at( -- 2.52.0 From ee6c1b7f99475395ff637d9d23eba980c982ca38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:27:29 +0800 Subject: [PATCH 04/32] =?UTF-8?q?=E4=BF=9D=E7=95=99=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=B5=8B=E8=AF=95=E9=80=82=E9=85=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将测试用用户与助手消息写入原始 Responses item 不恢复生产环境旧聊天持久化逻辑 --- .../src-tauri/src/agent/direct_runtime.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 2098314b9..580e3cc5c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4230,6 +4230,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } +#[cfg(test)] +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "user", + prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + )?; + append_direct_project_history_item_at(root, &item) +} + +#[cfg(test)] +fn persist_direct_codex_assistant_reply_at( + root: &Path, + client_turn_id: &str, + reply: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + reply, + Some(&format!("direct-codex:{client_turn_id}:assistant")), + )?; + append_direct_project_history_item_at(root, &item) +} + #[cfg(test)] mod tests { use super::*; -- 2.52.0 From b116a5d75cc8335f2b6e3502154fe4e92b642522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:31:48 +0800 Subject: [PATCH 05/32] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20DirectProject=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=AA=97=E5=8F=A3=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除旧的文本拼接、预算裁剪和遗漏标记逻辑 DirectProject 仅通过原始 item 注入恢复上下文 保留非 DirectProject 路径的既有会话实现 --- .../src-tauri/src/agent/codex_app_server.rs | 151 +----------------- .../src/agent/direct_project_history.rs | 10 +- 2 files changed, 9 insertions(+), 152 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 692ac87bc..05bc9b155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -3272,155 +3272,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } -/// Builds the prompt used when a new DirectProject thread needs replay. -/// -/// The JSONL history remains immutable; only the derived prompt is bounded. -/// When the full request exceeds the replay target, the oldest contiguous -/// records are omitted and a plain `system:` marker is prepended. -pub(crate) fn build_direct_codex_history_prompt( - root: &std::path::Path, - client_turn_id: &str, - current_prompt: &str, - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, -) -> Result { - let conversation = read_local_conversation_for_session_at(root, None, None)?; - let current_message_id = format!("direct-codex:{client_turn_id}:user"); - // The deliberately simple role-prefix format is part of the Direct - // replay contract. Do not introduce an envelope or implicit escaping - // here without updating the persisted-history compatibility decision. - let lines = conversation - .messages - .iter() - .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) - .map(|message| format!("{}: {}", message.role, message.content)) - .collect::>(); - let current_line = format!("user: {}", current_prompt.trim()); - let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); - let target_budget = direct_codex_replay_target_budget(llm, base_request)?; - if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { - return Ok(full_prompt); - } - - // The persisted project conversation is immutable. We only derive a - // bounded prompt for this replay, keeping the newest contiguous records. - let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; - let mut selected_reversed = Vec::new(); - for line in lines.iter().rev() { - let mut candidate_reversed = selected_reversed.clone(); - candidate_reversed.push(line.as_str()); - let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); - let candidate_prompt = format_direct_codex_replay_prompt( - &candidate_lines, - ¤t_line, - Some(omission_marker), - ); - if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { - selected_reversed.push(line.as_str()); - } else { - break; - } - } - - let selected_lines = selected_reversed.iter().rev().copied().collect::>(); - let marked_prompt = - format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); - if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { - return Ok(marked_prompt); - } - - // If the marker itself would push the request over the target, preserve - // the current user request and omit only the marker. - let current_only_prompt = - format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); - if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { - return Ok(current_only_prompt); - } - direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) -} - -const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = - "system: Earlier conversation history was omitted due to context budget."; - -fn format_direct_codex_replay_prompt( - history_lines: &[impl AsRef], - current_line: &str, - omission_marker: Option<&str>, -) -> String { - let mut lines = Vec::with_capacity(history_lines.len() + 2); - if let Some(marker) = omission_marker { - lines.push(marker.to_string()); - } - lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); - lines.push(current_line.to_string()); - lines.join("\n") -} - -fn direct_codex_replay_target_budget( - llm: &GameCreatorLlmConfig, - request: &LlmRunRequest, -) -> Result { - const SAFETY_MARGIN_TOKENS: u64 = 4_096; - let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); - let hard_budget = llm - .context_window_tokens - .checked_sub(max_output_tokens) - .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) - .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; - Ok(llm.auto_compact_token_limit.min(hard_budget)) -} - -fn direct_codex_replay_estimate( - base_request: &LlmRunRequest, - prompt: &str, -) -> Result { - let mut request = base_request.clone(); - let user = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; - user.content = prompt.to_string(); - Ok(request) -} - -fn direct_codex_replay_prompt_fits( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, - target_budget: u64, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - if estimated > target_budget { - return Ok(false); - } - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(true) -} - -fn direct_codex_replay_validate_context_budget( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(prompt.to_string()) -} - /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3506,6 +3357,7 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + /* legacy text replay tests removed with the prompt replay implementation #[test] fn direct_history_prompt_replays_all_project_messages_in_order() { let root = tempfile::tempdir().expect("temp dir"); @@ -3618,6 +3470,7 @@ mod tests { .expect_err("oversized request should fail"); assert!(error.contains("Direct replay 请求")); } + */ #[test] fn direct_item_activities_are_closed_safe_categories() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 31cf94aba..f47951dca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -70,13 +70,17 @@ pub(crate) fn append_direct_project_history_item_at( enforce_project_permission_policy(root, "conversation.write")?; let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; let path = history_path(root); + prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; let lock = project_append_lock_for(&path)?; let _append_guard = lock.lock("DirectProject 历史追加写")?; if let Some(item_id) = item.get("id").and_then(Value::as_str) { - if read_direct_project_history_items_at(root)?.iter().any(|existing| { + if let Some(existing) = read_direct_project_history_items_at(root)?.into_iter().find(|existing| { existing.get("id").and_then(Value::as_str) == Some(item_id) }) { - return Ok(()); + if &existing == item { + return Ok(()); + } + return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); } } let line = record(item)?; @@ -119,7 +123,7 @@ pub(crate) fn read_direct_project_history_items_at( root: &Path, ) -> Result, String> { let path = history_path(root); - if !path.exists() { + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { return Ok(Vec::new()); } let file = File::open(&path) -- 2.52.0 From 2a0a15639cfd07bc956d0dc0bb6ed07c74f5522f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:56:02 +0800 Subject: [PATCH 06/32] =?UTF-8?q?=E6=8B=86=E5=88=86=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=94=B6=E5=B0=BE=E5=B9=B6=E5=81=9C=E7=94=A8?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProject 历史累计逻辑拆到独立回合模块 浏览器仅显示项目对话,Rust 负责原始 item 持久化 拒绝空 rawResponseItem 并更新测试适配 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 141 ++------- .../src/agent/direct_project_history.rs | 74 +---- .../src/agent/direct_project_turn_history.rs | 48 ++++ .../src-tauri/src/agent/direct_runtime.rs | 12 +- apps/ai-game-creator-shell/src/App.tsx | 272 +++++------------- 6 files changed, 160 insertions(+), 389 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 526857c4c..cc37945ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -15,6 +15,7 @@ mod codex_provider_proxy; mod direct_codex_attachments; mod direct_codex_audit; mod direct_project_history; +mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -40,6 +41,7 @@ pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_project_history::*; +pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 05bc9b155..7105035a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2299,9 +2299,12 @@ impl CodexAppServerConnection { } } Some(CodexTurnEvent::RawItem(item)) => { - if self.inner.workspace_mode - == CodexAppServerWorkspaceMode::DirectProject - { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if item.is_null() { + return Err(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + )); + } append_direct_project_history_item_at(history_root, &item) .map_err(platform_llm::LlmError::Transport)?; direct_project_history.complete_item(&item); @@ -3353,125 +3356,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result }) } +#[cfg(test)] +pub(crate) fn build_direct_codex_history_prompt( + root: &Path, + _client_turn_id: &str, + current_prompt: &str, + _request: &LlmRunRequest, + _llm: &GameCreatorLlmConfig, +) -> Result { + let mut lines = read_direct_project_chat_history_at(root)? + .messages + .into_iter() + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + lines.push(format!("user: {}", current_prompt.trim())); + Ok(lines.join("\n")) +} + #[cfg(test)] mod tests { use super::*; - /* legacy text replay tests removed with the prompt replay implementation - #[test] - fn direct_history_prompt_replays_all_project_messages_in_order() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "history-project", "history").expect("init"); - for (role, content, message_id) in [ - ("user", "hello", "direct-codex:old:user"), - ( - "assistant", - "partial\nunexpected interrupt happened here", - "partial-id", - ), - ("tool", "file-read result", "tool-id"), - ("user", "stored raw request", "direct-codex:new-turn:user"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content: content.to_string(), - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &test_llm(), - ) - .expect("build prompt"); - assert_eq!( - prompt, - "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" - ); - } - - #[test] - fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "window-project", "window").expect("init"); - for (role, content, message_id) in [ - ("user", "old ".repeat(1_000), "old-user"), - ("assistant", "middle ".repeat(100), "middle-assistant"), - ("tool", "newest ".repeat(100), "newest-tool"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content, - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let mut llm = test_llm(); - llm.auto_compact_token_limit = 800; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let before = - std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &llm, - ) - .expect("build prompt"); - let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - - assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); - assert!(prompt.contains("tool: newest")); - assert!(!prompt.contains("user: old")); - assert!(prompt.ends_with("user: new request")); - assert_eq!(before, after); - } - - #[test] - fn direct_history_prompt_rejects_current_request_that_exceeds_context() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); - let mut llm = test_llm(); - llm.context_window_tokens = 5_000; - llm.auto_compact_token_limit = 1_000; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(100); - let error = build_direct_codex_history_prompt( - root.path(), - "new-turn", - &"request ".repeat(10_000), - &request, - &llm, - ) - .expect_err("oversized request should fail"); - assert!(error.contains("Direct replay 请求")); - } - */ - #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index f47951dca..c438990e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,56 +1,15 @@ -use super::*; -use crate::project::{append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for}; +use crate::config::prepare_game_creator_private_path_for_read; +use crate::project::{ + append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, +}; +use crate::{LocalConversationMessageRecord, LocalConversationResult}; use serde_json::Value; -use std::collections::BTreeMap; +use std::fs::File; use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; -#[derive(Default)] -pub(crate) struct DirectProjectHistoryAccumulator { - text_by_item_id: BTreeMap, -} - -impl DirectProjectHistoryAccumulator { - pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { - self.text_by_item_id - .entry(item_id.to_string()) - .or_default() - .push_str(delta); - } - - pub(crate) fn complete_item(&mut self, item: &Value) { - if let Some(item_id) = item.get("id").and_then(Value::as_str) { - self.text_by_item_id.remove(item_id); - } - } - - pub(crate) fn take_partial_items(&mut self) -> Vec { - std::mem::take(&mut self.text_by_item_id) - .into_iter() - .filter(|(_, text)| !text.is_empty()) - .map(|(item_id, text)| { - serde_json::json!({ - "type": "message", - "role": "assistant", - "id": item_id, - "content": [{"type": "output_text", "text": text}], - }) - }) - .collect() - } -} - -pub(crate) fn persist_direct_project_partial_items_at( - root: &Path, - accumulator: &mut DirectProjectHistoryAccumulator, -) -> Result<(), String> { - for item in accumulator.take_partial_items() { - append_direct_project_history_item_at(root, &item)?; - } - Ok(()) -} - fn history_path(root: &Path) -> PathBuf { root.join(".agent/conversations/project.jsonl") } @@ -74,9 +33,10 @@ pub(crate) fn append_direct_project_history_item_at( let lock = project_append_lock_for(&path)?; let _append_guard = lock.lock("DirectProject 历史追加写")?; if let Some(item_id) = item.get("id").and_then(Value::as_str) { - if let Some(existing) = read_direct_project_history_items_at(root)?.into_iter().find(|existing| { - existing.get("id").and_then(Value::as_str) == Some(item_id) - }) { + if let Some(existing) = read_direct_project_history_items_at(root)? + .into_iter() + .find(|existing| existing.get("id").and_then(Value::as_str) == Some(item_id)) + { if &existing == item { return Ok(()); } @@ -119,9 +79,7 @@ pub(crate) fn direct_project_user_message_item(prompt: &str) -> Value { }) } -pub(crate) fn read_direct_project_history_items_at( - root: &Path, -) -> Result, String> { +pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { return Ok(Vec::new()); @@ -153,8 +111,7 @@ pub(crate) fn read_direct_project_history_items_at( )); } }; - if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) - { + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { return Err(format!( "DirectProject 历史记录类型无效:{}", path.display() @@ -197,10 +154,7 @@ pub(crate) fn read_direct_project_chat_history_at( role: role.to_string(), content, agent_id: None, - message_id: item - .get("id") - .and_then(Value::as_str) - .map(str::to_string), + message_id: item.get("id").and_then(Value::as_str).map(str::to_string), updated_at: 0, }) }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs new file mode 100644 index 000000000..62e161d0b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -0,0 +1,48 @@ +use super::direct_project_history::append_direct_project_history_item_at; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct DirectProjectHistoryAccumulator { + text_by_item_id: BTreeMap, +} + +impl DirectProjectHistoryAccumulator { + pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { + self.text_by_item_id + .entry(item_id.to_string()) + .or_default() + .push_str(delta); + } + + pub(crate) fn complete_item(&mut self, item: &Value) { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + self.text_by_item_id.remove(item_id); + } + } + + fn take_partial_items(&mut self) -> impl Iterator + '_ { + std::mem::take(&mut self.text_by_item_id) + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(item_id, text)| { + serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }) + }) + } +} + +pub(crate) fn persist_direct_project_partial_items_at( + root: &Path, + accumulator: &mut DirectProjectHistoryAccumulator, +) -> Result<(), String> { + for item in accumulator.take_partial_items() { + append_direct_project_history_item_at(root, &item)?; + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 580e3cc5c..679908d4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4327,8 +4327,8 @@ mod tests { persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply) .expect("the App's same-id write converges idempotently"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:assistant"); let persisted = conversation .messages @@ -4341,7 +4341,7 @@ mod tests { assert!( persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复") .expect_err("same message identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } @@ -4358,8 +4358,8 @@ mod tests { persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) .expect("retry reuses the same user message identity"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:user"); let persisted = conversation .messages @@ -4372,7 +4372,7 @@ mod tests { assert!( persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") .expect_err("same turn identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e0507c47b..952319540 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -495,11 +495,6 @@ export function App({ const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); const directCodexTransientReplyRef = useRef(''); - const directCodexInterruptedPartialRef = useRef<{ - projectPath: string; - text: string; - messageId: string; - } | null>(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -510,10 +505,6 @@ export function App({ lastSequence: number; receivedDirectUpdate: boolean; } | null>(null); - const recoveredDirectCodexTurnClaimsRef = useRef(new Set()); - const directCodexClaimReleaseOnConversationWriteFailureRef = useRef( - new Map(), - ); const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null @@ -1675,6 +1666,12 @@ export function App({ projectConversationWriteConfirmedRef.current = null; projectConversationWriteCancelledRef.current = null; } + // DirectProject history is written by Rust from raw app-server items. + // The browser only renders that projection and must not append chat rows. + if (projectSupervisorOnly && directCodexProductRuntime) { + savedConversationCountRef.current = messages.length; + return; + } const start = savedConversationCountRef.current; const pendingMessages = messages.slice(start); if (pendingMessages.length === 0) { @@ -1745,7 +1742,6 @@ export function App({ return; } conversationWriteInFlightRef.current = true; - let failedDirectTerminalMessageCount: number | null = null; void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { @@ -1760,56 +1756,22 @@ export function App({ savedConversationCountRef.current = start + index + 1; continue; } - try { - await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, + await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), + message: { + role: message.role, + content: message.text, agentId: null, - ...(message.messageId ? { messageId: message.messageId } : {}), - message: { - role: message.role, - content: message.text, - agentId: null, - ...(typeof message.updatedAt === 'number' - ? { updatedAt: message.updatedAt } - : {}), - }, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, - ); - } catch (error) { - const claimKey = message.messageId - ? directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ) - : undefined; - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId!, - ); - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - failedDirectTerminalMessageCount = start + index + 1; - } - throw error; - } - if (message.messageId) { - const claimKey = - directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ); - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId, - ); - if (localProjectPathRef.current === nextProjectPath) { - // Any history read started before this terminal append may hold - // A stale history snapshot may still be missing this terminal - // append. Invalidate it before releasing the in-memory claim. - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - } - } + }, + ); wroteMessage = true; savedConversationCountRef.current = start + index + 1; } @@ -1824,12 +1786,10 @@ export function App({ } })() .catch((error) => { - savedConversationCountRef.current = failedDirectTerminalMessageCount - ? Math.max( - savedConversationCountRef.current, - failedDirectTerminalMessageCount, - ) - : Math.min(savedConversationCountRef.current, start); + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + start, + ); setWorkspaceStatus( `项目对话保存失败:${ error instanceof Error ? error.message : String(error) @@ -1859,6 +1819,7 @@ export function App({ conversationWriteVersion, pendingUiConfirmation, projectSupervisorOnly, + directCodexProductRuntime, ]); function appendLocalPermissionLog( @@ -5350,20 +5311,45 @@ export function App({ const appendDirectUserMessageIfMissing = ( current: ChatMessage[], ): ChatMessage[] => { - return current.some( - (message) => message.messageId === directUserMessageId, - ) - ? current - : [ - ...current, - { - role: 'user' as const, - text: prompt, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - }, - ]; + if ( + current.some((message) => message.messageId === directUserMessageId) + ) { + return current; + } + let optimisticIndex = -1; + for (let index = current.length - 1; index >= 0; index -= 1) { + const message = current[index]; + if ( + message?.role === 'user' && + message.text === prompt && + !message.messageId + ) { + optimisticIndex = index; + break; + } + } + if (optimisticIndex >= 0) { + return current.map((message, index) => + index === optimisticIndex + ? { + ...message, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + } + : message, + ); + } + return [ + ...current, + { + role: 'user' as const, + text: prompt, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + }, + ]; }; const appendDirectAssistantMessage = ( current: ChatMessage[], @@ -5387,41 +5373,6 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - const persistDirectAssistantMessage = (text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directAssistantMessageId, - message: { - role: 'assistant', - content: text, - agentId: null, - }, - }, - ); - const persistDirectPartialMessage = (messageId: string, text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId, - message: { - role: 'assistant', - // An interrupted partial is intentionally a normal assistant - // record so replay sees exactly what Codex emitted before the - // disconnect; the marker is product data, not UI metadata. - content: `${text.trim()}\nunexpected interrupt happened here`, - agentId: null, - }, - }, - ); - const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; - recoveredDirectCodexTurnClaimsRef.current.add( - recoveredDirectCodexTurnClaimKey, - ); activeDirectCodexTurnRef.current = { projectPath: directProjectPath, turnId: clientTurnId, @@ -5436,36 +5387,6 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { - const interruptedPartial = directCodexInterruptedPartialRef.current; - if ( - interruptedPartial?.projectPath === directProjectPath && - interruptedPartial.text.trim() - ) { - await persistDirectPartialMessage( - interruptedPartial.messageId, - interruptedPartial.text, - ); - directCodexInterruptedPartialRef.current = null; - } - // Rust owns the normalized user record for attachment turns so the - // durable message includes the same bounded project mapping that is - // sent to Codex. Plain turns keep the optimistic browser write; the - // Rust writer then converges on it through messageId idempotency. - if (!attachments?.length) { - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, - agentId: null, - }, - }, - ); - } const directTurnInput: { projectPath: string; prompt: string; @@ -5487,28 +5408,11 @@ export function App({ 'chat_with_game_creator_direct_codex', directTurnInput, ); - try { - await persistDirectAssistantMessage(reply); - } catch (error) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - `陶泥儿回复保存失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the in-memory turn claim can be - // released without reopening the Provider side effect. Invalidate - // any history snapshot captured before Rust committed the terminal - // reply first. + // Rust already persisted the complete raw response items. Invalidate + // any history snapshot captured before the turn completed. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setMessages((current) => @@ -5520,9 +5424,6 @@ export function App({ } } catch (error) { if (isDirectCodexTurnAlreadyRunningError(error)) { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( @@ -5542,46 +5443,7 @@ export function App({ '陶泥儿智能创作', true, ); - const partial = directCodexTransientReplyRef.current.trim(); - if (partial) { - const partialMessageId = - globalThis.crypto?.randomUUID?.() || - `direct-partial-${Date.now().toString(36)}`; - directCodexInterruptedPartialRef.current = { - projectPath: directProjectPath, - text: partial, - messageId: partialMessageId, - }; - try { - await persistDirectPartialMessage(partialMessageId, partial); - } catch { - // The next user send retries this idempotent append before - // constructing the replay prompt. - } - } - try { - await persistDirectAssistantMessage(visibleMessage); - if (localProjectPathRef.current === directProjectPath) { - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } catch { - if (localProjectPathRef.current === directProjectPath) { - // Do not release the claim while the React conversation writer - // can still persist this terminal record. That writer releases - // the claim only after its exact append resolves or rejects. - directCodexClaimReleaseOnConversationWriteFailureRef.current.set( - directAssistantMessageId, - recoveredDirectCodexTurnClaimKey, - ); - } else { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } - } + projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError(visibleMessage); -- 2.52.0 From c52c3aa106fec896de13f08ef4d3fbc507ba956e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:56:28 +0800 Subject: [PATCH 07/32] =?UTF-8?q?=E8=A1=A5=E5=85=85=20DirectProject=20?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E5=AE=9E=E7=8E=B0=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录线程注入与用户消息写入顺序 明确空 rawResponseItem 错误及浏览器只读边界 补充历史模块职责位置 --- ...方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 58f0537b8..ec03bae19 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -22,8 +22,8 @@ DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 ## 正常回合 -1. 将本轮用户文本构造成 Responses `message` item 并追加、flush。 -2. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 +1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 +2. 新线程先把历史 item 数组一次注入;注入成功后,将本轮用户文本构造成 Responses `message` item 并追加、flush。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -39,12 +39,16 @@ AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed 合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。 +`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。 + ## 恢复 创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。 聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 +DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 + ## 写入与损坏边界 写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。 -- 2.52.0 From 6940cad2d3b62d270a19ef34acab9adb6965d8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 17:57:52 +0800 Subject: [PATCH 08/32] =?UTF-8?q?=E7=99=BB=E8=AE=B0=20DirectProject=20?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E6=96=B9=E6=A1=88=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在文档入口增加原始 item 持久化与异常恢复方案 同步 DirectProject 专题文档地图 --- docs/README.md | 1 + .../shared-memory/document-map.md | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index d44e466ad..0b04d41c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 9d754cb5d..ffd3726a7 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -22,14 +22,15 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` -2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` -3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` -4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` -6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` -7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -9. UI 编辑器、宿主壳和当前测试专题文档 +2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` +3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` +4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` +5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` +6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +10. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: -- 2.52.0 From a401b9504a52a0afcdaa90696957e8af59202a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 21:54:53 +0800 Subject: [PATCH 09/32] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=86=99=E5=85=A5=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将本地历史持久化失败映射为 InvalidRequest --- .../src-tauri/src/agent/codex_app_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 7105035a9..89be0ef79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2306,7 +2306,7 @@ impl CodexAppServerConnection { )); } append_direct_project_history_item_at(history_root, &item) - .map_err(platform_llm::LlmError::Transport)?; + .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); } } -- 2.52.0 From 4731113d43d1d15f8a940ca98ade7a612c602af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 21:55:51 +0800 Subject: [PATCH 10/32] =?UTF-8?q?=E8=AE=B0=E5=BD=95=20DirectProject=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=20itemId=20=E5=8D=8F=E8=AE=AE=E5=91=8A?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留缺失 itemId 事件的现有丢弃行为并输出诊断日志 --- .../src-tauri/src/agent/codex_app_server.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 89be0ef79..bb509ec4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2850,6 +2850,9 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) else { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); continue; }; CodexTurnEvent::AgentMessageDelta { -- 2.52.0 From 7791bd7de08e55291ef2c3b713c12b2734ec108f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 10:40:28 +0800 Subject: [PATCH 11/32] =?UTF-8?q?=E5=88=86=E7=A6=BB=20DirectProject=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DirectProject 专用读写命令 恢复 legacy 对话命令的单一职责 更新工作台 DirectProject 读取调用 --- .../src-tauri/src/commands.rs | 35 ++++++++++++------- .../src-tauri/src/main.rs | 2 ++ apps/ai-game-creator-shell/src/App.tsx | 11 +++--- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index aef229a17..73ea34142 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4708,12 +4708,18 @@ pub(crate) fn read_local_conversation( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - if agent_id.is_none() && session_id.is_none() { - return read_direct_project_chat_history_at(root); - } read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } +#[tauri::command] +pub(crate) fn read_direct_project_conversation( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -4724,15 +4730,6 @@ pub(crate) fn append_local_conversation_message( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; - if agent_id.is_none() && session_id.is_none() { - let item = direct_project_local_message_item( - &message.role, - &message.content, - message_id.as_deref(), - )?; - append_direct_project_history_item_at(root, &item)?; - return read_direct_project_chat_history_at(root); - } let _lock = acquire_project_write_lock(root, "conversation.write")?; match message_id.as_deref() { Some(message_id) => append_local_conversation_message_for_session_idempotent_at( @@ -4751,6 +4748,20 @@ pub(crate) fn append_local_conversation_message( } } +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn build_local_project_index( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index b49bd19d9..1f0e6345c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2573,7 +2573,9 @@ fn main() { set_active_game_creator_agent_session, archive_game_creator_agent_session, read_local_conversation, + read_direct_project_conversation, append_local_conversation_message, + append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, export_local_project_package, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 952319540..cadf990b4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -2576,11 +2576,12 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; const projectConversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: null, - }, + directCodexProductRuntime + ? 'read_direct_project_conversation' + : 'read_local_conversation', + directCodexProductRuntime + ? { projectPath: nextProjectPath } + : { projectPath: nextProjectPath, agentId: null }, ); let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; -- 2.52.0 From b0a1ee0de238758144770c2225d50595439cd021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 10:42:13 +0800 Subject: [PATCH 12/32] =?UTF-8?q?=E4=BC=98=E5=8C=96=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=B9=82=E7=AD=89=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按文件尾部反向分块查找带 id 的历史 item 避免每次追加都完整读取并扫描历史文件 --- .../src/agent/direct_project_history.rs | 93 ++++++++++++++++++- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index c438990e0..edad7c9c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -5,7 +5,7 @@ use crate::project::{ use crate::{LocalConversationMessageRecord, LocalConversationResult}; use serde_json::Value; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; @@ -22,6 +22,92 @@ fn record(item: &Value) -> Result { .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) } +const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; + +fn find_direct_project_history_item_by_id_at( + path: &Path, + item_id: &str, +) -> Result, String> { + let mut file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + let mut pending = Vec::new(); + let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; + + loop { + if position == 0 { + break; + } + let read_len = usize::try_from(position) + .unwrap_or(usize::MAX) + .min(chunk.len()); + position -= read_len as u64; + file.seek(SeekFrom::Start(position)) + .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; + file.read_exact(&mut chunk[..read_len]) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + + let mut combined = Vec::with_capacity(read_len + pending.len()); + combined.extend_from_slice(&chunk[..read_len]); + combined.extend_from_slice(&pending); + let mut line_end = combined.len(); + while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { + let line = &combined[newline + 1..line_end]; + if !line.is_empty() { + if let Some(item) = direct_project_history_item_from_line(path, line)? { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + line_end = newline; + } + pending = combined[..line_end].to_vec(); + } + + if !pending.is_empty() { + if let Some(item) = direct_project_history_item_from_line(path, &pending)? { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + Ok(None) +} + +fn direct_project_history_item_from_line( + path: &Path, + line: &[u8], +) -> Result, String> { + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(None); + } + let parsed: Value = serde_json::from_slice(line) + .map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + return Ok(None); + } + Ok(Some(item)) +} + pub(crate) fn append_direct_project_history_item_at( root: &Path, item: &Value, @@ -33,10 +119,7 @@ pub(crate) fn append_direct_project_history_item_at( let lock = project_append_lock_for(&path)?; let _append_guard = lock.lock("DirectProject 历史追加写")?; if let Some(item_id) = item.get("id").and_then(Value::as_str) { - if let Some(existing) = read_direct_project_history_items_at(root)? - .into_iter() - .find(|existing| existing.get("id").and_then(Value::as_str) == Some(item_id)) - { + if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { if &existing == item { return Ok(()); } -- 2.52.0 From ad3d04f51a6aeb77fd7eefc622ef879c8b1f35ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 10:51:06 +0800 Subject: [PATCH 13/32] =?UTF-8?q?=E6=B8=85=E7=90=86=20DirectProject=20?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取消 Rust 预写用户消息,改为只保存 Codex 原始回传 过滤技能、权限和环境上下文内部 item 增加内部上下文过滤回归测试 --- .../src-tauri/src/agent/codex_app_server.rs | 5 -- .../src/agent/direct_project_history.rs | 67 ++++++++++++++++--- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index bb509ec4d..670ecc567 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2149,11 +2149,6 @@ impl CodexAppServerConnection { .await .map_err(platform_llm::LlmError::Transport)?; } - append_direct_project_history_item_at( - history_root, - &direct_project_user_message_item(current_prompt), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index edad7c9c8..e51c425ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -9,6 +9,28 @@ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ + "host_skills.instructions", + "permissions.instructions", + "environments.environment_context", +]; + +pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { + if matches!( + item.get("role").and_then(Value::as_str), + Some("developer" | "system") + ) { + return true; + } + item.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + .and_then(Value::as_array) + .is_some_and(|kinds| { + kinds + .iter() + .filter_map(Value::as_str) + .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind)) + }) +} fn history_path(root: &Path) -> PathBuf { root.join(".agent/conversations/project.jsonl") @@ -113,6 +135,9 @@ pub(crate) fn append_direct_project_history_item_at( item: &Value, ) -> Result<(), String> { enforce_project_permission_policy(root, "conversation.write")?; + if is_direct_project_internal_context_item(item) { + return Ok(()); + } let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; let path = history_path(root); prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; @@ -154,14 +179,6 @@ pub(crate) fn direct_project_local_message_item( Ok(item) } -pub(crate) fn direct_project_user_message_item(prompt: &str) -> Value { - serde_json::json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": prompt}], - }) -} - pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { @@ -204,6 +221,9 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result"}] + }))); + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": ""}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["environments.environment_context"] + } + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "ls ui"}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["user.text"] + } + }))); + } +} -- 2.52.0 From 1fdfe4624c46ca7cfaf3775b8de209c247f3b74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 10:51:24 +0800 Subject: [PATCH 14/32] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=BF=87=E6=BB=A4=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录用户消息只由 Codex 原始 item 产生 记录内部启动上下文不进入项目历史 --- ...术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index ec03bae19..aba84d27e 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -23,7 +23,7 @@ DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后,将本轮用户文本构造成 Responses `message` item 并追加、flush。 +2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -41,6 +41,8 @@ AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed `rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。 +Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。 + ## 恢复 创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。 -- 2.52.0 From 61c33cd3c7fdc8a9eb867562aaa1c617462fd721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 10:56:04 +0800 Subject: [PATCH 15/32] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=8E=86=E5=8F=B2=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史文件不存在时跳过幂等反向扫描 保留首条带 id item 的正常追加 --- .../src-tauri/src/agent/direct_project_history.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index e51c425ae..b552a53b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -140,15 +140,18 @@ pub(crate) fn append_direct_project_history_item_at( } let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; let path = history_path(root); - prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; + let history_exists = + prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; let lock = project_append_lock_for(&path)?; let _append_guard = lock.lock("DirectProject 历史追加写")?; - if let Some(item_id) = item.get("id").and_then(Value::as_str) { - if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { - if &existing == item { - return Ok(()); + if history_exists { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { + if &existing == item { + return Ok(()); + } + return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); } - return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); } } let line = record(item)?; -- 2.52.0 From 462db8f3bf121ad5c2a2bcad8a8b62452ae86ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 11:51:36 +0800 Subject: [PATCH 16/32] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=8E=86=E5=8F=B2=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 异常收尾时尝试写入全部未完成消息 保留首个落盘错误并避免后续部分历史被丢弃 --- .../src/agent/direct_project_turn_history.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index 62e161d0b..c2c317692 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -41,8 +41,14 @@ pub(crate) fn persist_direct_project_partial_items_at( root: &Path, accumulator: &mut DirectProjectHistoryAccumulator, ) -> Result<(), String> { - for item in accumulator.take_partial_items() { - append_direct_project_history_item_at(root, &item)?; + let items = accumulator.take_partial_items().collect::>(); + let mut first_error = None; + for item in items { + if let Err(error) = append_direct_project_history_item_at(root, &item) { + if first_error.is_none() { + first_error = Some(error); + } + } } - Ok(()) + first_error.map_or(Ok(()), Err) } -- 2.52.0 From baba45323ab9f28e89a1d3a91537d2879161f125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 11:52:56 +0800 Subject: [PATCH 17/32] =?UTF-8?q?=E4=BC=98=E5=8C=96=20DirectProject=20?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E5=8E=86=E5=8F=B2=E7=B4=AF=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在 DirectProject 模式记录增量文本 避免其他工作区为每个事件创建无用副本 --- .../src-tauri/src/agent/codex_app_server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 670ecc567..e5411addc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2278,7 +2278,9 @@ impl CodexAppServerConnection { }; match event { Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { - direct_project_history.observe_delta(&item_id, &delta); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + direct_project_history.observe_delta(&item_id, &delta); + } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::AccumulatedText( -- 2.52.0 From 9a48503e15b740f0b915006bb7899fbfd84b1e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 11:54:53 +0800 Subject: [PATCH 18/32] =?UTF-8?q?=E9=81=BF=E5=85=8D=20DirectProject=20?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E9=98=BB=E5=A1=9E=20Tokio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原始响应历史写入移到 spawn_blocking 保持事件顺序并避免阻塞异步收集循环 --- .../src-tauri/src/agent/codex_app_server.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index e5411addc..ed1209ddf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2302,8 +2302,18 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - append_direct_project_history_item_at(history_root, &item) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let history_root = history_root.to_path_buf(); + let history_item = item.clone(); + tokio::task::spawn_blocking(move || { + append_direct_project_history_item_at(&history_root, &history_item) + }) + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "DirectProject 历史落盘任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); } } -- 2.52.0 From 15a906931b268067aec3fbbbb89d87a3cbe064ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 11:56:30 +0800 Subject: [PATCH 19/32] =?UTF-8?q?=E4=BF=9D=E7=95=99=E7=BC=BA=E5=B0=91=20it?= =?UTF-8?q?emId=20=E7=9A=84=20DirectProject=20=E5=A2=9E=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为协议异常的增量生成稳定回退 item 标识 避免丢失用户可见流文本和异常收尾历史 --- .../src-tauri/src/agent/codex_app_server.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index ed1209ddf..0d7017600 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2852,18 +2852,19 @@ async fn read_game_creator_codex_app_server_stdout( else { continue; }; - let Some(item_id) = params + let item_id = params .get("itemId") .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) - else { - eprintln!( - "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" - ); - continue; - }; + .map(str::to_string) + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); CodexTurnEvent::AgentMessageDelta { - item_id: item_id.to_string(), + item_id, delta: delta.to_string(), } } -- 2.52.0 From 1f2e9ed87ad23ed97148a866c7902a2c86c57870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 12:04:03 +0800 Subject: [PATCH 20/32] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20DirectProject=20?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=8E=86=E5=8F=B2=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录当前不预写用户消息的实现边界 补充缺失 itemId 的回退累积规则 --- ...方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index aba84d27e..5f7bca509 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-04` +更新时间:`2026-09-05` ## 目标 @@ -16,7 +16,7 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。用户 message 在 `turn/start` 前追加;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 @@ -31,7 +31,7 @@ DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。 -`item/agentMessage/delta` 带有 `itemId`。AGC 在内存中按 `itemId` 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: +`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: ```json {"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}} -- 2.52.0 From 38d3789f297d00603fdf7ba4bcb6f761f52ca625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:06:59 +0800 Subject: [PATCH 21/32] =?UTF-8?q?=E8=AF=B4=E6=98=8E=20Direct=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=B6=88=E6=81=AF=E6=8C=81=E4=B9=85=E5=8C=96=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标注 messages 仅是乐观界面投影 说明 Direct 历史由 Rust 原始响应负责写入 --- apps/ai-game-creator-shell/src/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cadf990b4..cb5a6aa9d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1669,6 +1669,10 @@ export function App({ // DirectProject history is written by Rust from raw app-server items. // The browser only renders that projection and must not append chat rows. if (projectSupervisorOnly && directCodexProductRuntime) { + // `messages` is only an optimistic UI projection in Direct mode; it is + // intentionally not proof of durability. Rust owns the raw response + // history, so this effect must not route these rows through the generic + // browser conversation writer. savedConversationCountRef.current = messages.length; return; } -- 2.52.0 From 1a08df517fbe07a32e03791fdd4d2d651720ebf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:30:57 +0800 Subject: [PATCH 22/32] =?UTF-8?q?=E8=AE=A9=20Direct=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=81=B5=E5=AE=88=E9=A1=B9=E7=9B=AE=E5=86=99=E5=85=A5=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct 回合启动前检查 conversation.write 策略 确认后复用同一 clientTurnId 重试,拒绝则不启动回合 --- apps/ai-game-creator-shell/src/App.tsx | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb5a6aa9d..5674806e7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -387,6 +387,7 @@ type ExecuteChatAgentReplyInput = { clientTurnId?: string; creationType?: HomeCreationType | null; attachments?: DirectCodexTurnAttachment[]; + directPolicyChecked?: boolean; }; export function App({ @@ -5296,6 +5297,7 @@ export function App({ clientTurnId: directConversationTurnId, creationType, attachments, + directPolicyChecked = false, }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. @@ -5305,6 +5307,38 @@ export function App({ if (directProjectPath && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); + if (!directPolicyChecked) { + try { + const policyPaused = await queueProjectPolicyConfirmationIfNeeded( + directInvoke, + 'conversation.write', + directProjectPath, + '写入 DirectProject 对话历史', + 'DirectProject 对话写入需要确认。', + () => { + void executeChatAgentReply({ + prompt, + clientTurnId, + creationType, + attachments, + directPolicyChecked: true, + }); + }, + ); + if (policyPaused) { + return; + } + } catch (error) { + if (localProjectPathRef.current === directProjectPath) { + setProjectSupervisorRuntimeError( + `DirectProject 对话权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return; + } + } const directUserMessageId = directCodexConversationMessageId( clientTurnId, 'user', -- 2.52.0 From d1f080478ee91caecf70c6b3a124bfcb595b9680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 19:25:20 +0800 Subject: [PATCH 23/32] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=9D=83=E9=99=90=E4=B8=8E=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 DirectProject 原生对话读写命令的 native-only 白名单 复用项目级 DirectProject 写权限检查,避免连续回合重复读取策略 更新 AppSurface 测试以覆盖 Rust 负责历史持久化的直连语义 --- .../scripts/check-config.mjs | 2 + apps/ai-game-creator-shell/src/App.tsx | 8 +- .../tests/appSurface/home.suite.ts | 84 ++++++++++++++----- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 9833f1251..4c8c53722 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -107,11 +107,13 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'append_direct_project_conversation_message', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', + 'read_direct_project_conversation', 'stop_local_game_preview_if_matches', ]; const sourceExtensions = new Set([ diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5674806e7..8d4c77580 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5307,7 +5307,10 @@ export function App({ if (directProjectPath && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - if (!directPolicyChecked) { + if ( + !directPolicyChecked && + projectConversationWriteConfirmedRef.current !== directProjectPath + ) { try { const policyPaused = await queueProjectPolicyConfirmationIfNeeded( directInvoke, @@ -5316,6 +5319,8 @@ export function App({ '写入 DirectProject 对话历史', 'DirectProject 对话写入需要确认。', () => { + projectConversationWriteConfirmedRef.current = + directProjectPath; void executeChatAgentReply({ prompt, clientTurnId, @@ -5328,6 +5333,7 @@ export function App({ if (policyPaused) { return; } + projectConversationWriteConfirmedRef.current = directProjectPath; } catch (error) { if (localProjectPathRef.current === directProjectPath) { setProjectSupervisorRuntimeError( diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 5ab564020..5274bda75 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2122,6 +2122,7 @@ export function registerHomeProjectCreationTests() { 'existing-project', '已有项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { @@ -2131,14 +2132,45 @@ export function registerHomeProjectCreationTests() { if (command === 'append_local_permission_log') { return {}; } - if (command === 'append_local_conversation_message') { + if (command === 'hydrate_game_creator_plan_gdd_state') { + return null; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, - messages: [], + sessionId: null, + messages: [...persistedMessages], }; } + if (command === 'append_local_conversation_message') { + throw new Error( + 'DirectProject must not use browser conversation writer', + ); + } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + }, + { + role: 'assistant', + content: 'DIRECT_EXISTING_PROJECT_OK', + messageId: `direct-codex:${clientTurnId}:assistant`, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2165,32 +2197,18 @@ export function registerHomeProjectCreationTests() { }, ); }); - const persistedTurnCall = invoke.mock.calls.findIndex( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId !== undefined, - ); const directTurnCall = invoke.mock.calls.findIndex( ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(persistedTurnCall).toBeGreaterThanOrEqual(0); - expect(persistedTurnCall).toBeLessThan(directTurnCall); - const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as - | Record - | undefined; const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as | Record | undefined; - expect(persistedTurnArgs).toEqual({ - projectPath, - agentId: null, - messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`, - message: { - role: 'user', - content: '继续修改已有项目', - agentId: null, - }, - }); + expect(directTurnCall).toBeGreaterThanOrEqual(0); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String)); expect(invoke).not.toHaveBeenCalledWith( 'create_automatic_local_game_project', ); @@ -2214,7 +2232,10 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return manifest; } - if (command === 'read_local_conversation') { + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, @@ -2257,6 +2278,23 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', + messageId: `direct-codex:${clientTurnId}:assistant`, + updatedAt: 2, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); -- 2.52.0 From 0b0d48d9b4d715aa9723c3359c0a37abc4c489de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 10:51:34 +0800 Subject: [PATCH 24/32] =?UTF-8?q?=E8=BF=87=E6=BB=A4=20DirectProject=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 识别 Codex 环境与上下文标签形式的 user item 补充无 passthrough 元数据的回归测试 --- .../src/agent/direct_project_history.rs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index b552a53b9..3b67e47eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -15,6 +15,16 @@ const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ "environments.environment_context", ]; +const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), +]; + pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { if matches!( item.get("role").and_then(Value::as_str), @@ -22,7 +32,8 @@ pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { ) { return true; } - item.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + if item + .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") .and_then(Value::as_array) .is_some_and(|kinds| { kinds @@ -30,6 +41,22 @@ pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { .filter_map(Value::as_str) .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind)) }) + { + return true; + } + item.get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + let Some(text) = part.get("text").and_then(Value::as_str) else { + return false; + }; + let text = text.trim(); + DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS + .iter() + .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + }) + }) } fn history_path(root: &Path) -> PathBuf { @@ -96,7 +123,9 @@ fn find_direct_project_history_item_by_id_at( } if !pending.is_empty() { - if let Some(item) = direct_project_history_item_from_line(path, &pending)? { + // The append path repairs an unterminated final JSONL record before + // writing. A duplicate scan must not reject that repairable tail. + if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { if item.get("id").and_then(Value::as_str) == Some(item_id) { return Ok(Some(item)); } @@ -302,4 +331,24 @@ mod tests { } }))); } + + #[test] + fn filters_codex_contextual_user_item_without_passthrough_metadata() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n/tmp/project\n" + }] + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "请读取 中的说明" + }] + }))); + } } -- 2.52.0 From b6d6476459d972041569eff523c604930e7205e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 11:05:33 +0800 Subject: [PATCH 25/32] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=B8=8E=E7=BA=BF=E7=A8=8B=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 尾行损坏时先允许底层 JSONL 追加逻辑修复 历史注入失败时淘汰未完成初始化的 Codex thread turn/start 传递稳定 clientUserMessageId --- .../src-tauri/src/agent/codex_app_server.rs | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 0d7017600..dcf02c0d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -977,6 +977,7 @@ fn codex_app_server_turn_start_params( model: &str, workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, + client_user_message_id: Option<&str>, ) -> serde_json::Value { let approval_policy = "never"; let mut params = serde_json::json!({ @@ -994,6 +995,13 @@ fn codex_app_server_turn_start_params( "networkAccess": false }); } + if let Some(client_user_message_id) = client_user_message_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + params["clientUserMessageId"] = + serde_json::Value::String(client_user_message_id.to_string()); + } params } @@ -2140,14 +2148,23 @@ impl CodexAppServerConnection { )); } if thread_created { - let items = read_direct_project_history_items_at(history_root) - .map_err(platform_llm::LlmError::InvalidRequest)?; - self.request( - "thread/inject_items", - serde_json::json!({"threadId": thread_id, "items": items}), - ) - .await - .map_err(platform_llm::LlmError::Transport)?; + let items = match read_direct_project_history_items_at(history_root) { + Ok(items) => items, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + }; + if let Err(error) = self + .request( + "thread/inject_items", + serde_json::json!({"threadId": thread_id, "items": items}), + ) + .await + { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::Transport(error)); + } } } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { @@ -2186,6 +2203,7 @@ impl CodexAppServerConnection { model, &self.inner.workspace_path, self.inner.workspace_mode, + _direct_client_turn_id, ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -3630,6 +3648,7 @@ mod tests { "fixture-model", workspace, CodexAppServerWorkspaceMode::DirectHome, + None, ); assert_eq!(turn["threadId"], "home-thread"); assert_eq!(turn["approvalPolicy"], "never"); @@ -3690,6 +3709,7 @@ mod tests { "fixture-model", &workspace, CodexAppServerWorkspaceMode::DirectProject, + None, ); assert_eq!( turn.pointer("/sandboxPolicy/writableRoots/0"), -- 2.52.0 From 06911e6f2724ffe24cbee587f03c45312b31700d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 11:11:48 +0800 Subject: [PATCH 26/32] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=81=A2=E5=A4=8D=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录上下文用户消息过滤与坏线程淘汰规则 明确 clientUserMessageId 不提供回合级 exactly-once --- ...方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 5f7bca509..de0c1b0a2 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-05` +更新时间:`2026-09-07` ## 目标 @@ -41,11 +41,13 @@ AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed `rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。 -Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。 +Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。 ## 恢复 -创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。 +创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。 + +`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。 聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 -- 2.52.0 From 330605e682de369fde98c6c1aadfcff72f524dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 11:25:12 +0800 Subject: [PATCH 27/32] =?UTF-8?q?=E8=A1=A5=E5=85=85=20DirectProject=20?= =?UTF-8?q?=E5=B0=BE=E8=A1=8C=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖幂等扫描遇到未完成 JSONL 尾行的追加路径 --- .../src/agent/direct_project_history.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 3b67e47eb..531bf5231 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -304,7 +304,10 @@ pub(crate) fn read_direct_project_chat_history_at( #[cfg(test)] mod tests { - use super::is_direct_project_internal_context_item; + use super::{ + append_direct_project_history_item_at, history_path, + is_direct_project_internal_context_item, read_direct_project_history_items_at, + }; use serde_json::json; #[test] @@ -351,4 +354,28 @@ mod tests { }] }))); } + + #[test] + fn append_repairs_truncated_tail_before_idempotency_scan() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复") + .expect("init project"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + std::fs::write( + &path, + br#"{"type":"response_item","payload":{"type":"message"}"#, + ) + .expect("write truncated history tail"); + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": "tail-repair-item", + "content": [{"type": "output_text", "text": "已修复"}] + }); + append_direct_project_history_item_at(root.path(), &item).expect("repair and append"); + let items = + read_direct_project_history_items_at(root.path()).expect("read repaired history"); + assert_eq!(items, vec![item]); + } } -- 2.52.0 From 3ef3e403836c0f4c212cfd584b1a50564ec187b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 11:28:21 +0800 Subject: [PATCH 28/32] =?UTF-8?q?=E4=BC=A0=E9=80=92=20DirectProject=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E7=94=A8=E6=88=B7=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 向 turn/start 发送稳定 clientUserMessageId 补充项目模式协议参数回归断言 --- .../src-tauri/src/agent/codex_app_server.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index dcf02c0d8..a93dd519d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -3709,8 +3709,9 @@ mod tests { "fixture-model", &workspace, CodexAppServerWorkspaceMode::DirectProject, - None, + Some("direct-turn-0001"), ); + assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( turn.pointer("/sandboxPolicy/writableRoots/0"), Some(&serde_json::json!(workspace)) -- 2.52.0 From 908541f639bc0bb30602df21d372c96d048faa2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 11:35:19 +0800 Subject: [PATCH 29/32] =?UTF-8?q?=E5=AE=8C=E5=96=84=20DirectProject=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=A0=87=E7=AD=BE=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 Codex 内部与外部上下文包裹格式 保持普通用户文本的完整标签引用可见 --- .../src/agent/direct_project_history.rs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 531bf5231..6919a89e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -16,6 +16,7 @@ const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ ]; const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ + ("# AGENTS.md instructions", ""), ("", ""), ("", ""), ("", ""), @@ -25,6 +26,24 @@ const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ ("", ""), ]; +fn is_known_contextual_user_text(text: &str) -> bool { + let text = text.trim(); + if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS + .iter() + .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + { + return true; + } + if text.starts_with("") { + return true; + } + text.starts_with("') + .and_then(|(start, _)| start.strip_prefix(""))) +} + pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { if matches!( item.get("role").and_then(Value::as_str), @@ -51,10 +70,7 @@ pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { let Some(text) = part.get("text").and_then(Value::as_str) else { return false; }; - let text = text.trim(); - DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS - .iter() - .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + is_known_contextual_user_text(text) }) }) } -- 2.52.0 From ef64d2fa52d5ba4127397eab9a95635345383b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 15:40:21 +0800 Subject: [PATCH 30/32] =?UTF-8?q?=E6=98=8E=E7=A1=AE=20DirectProject=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规定 AGC 在 turn/start 前幂等预写规范化 user item 规定 Codex user echo 只做观察与关联,不再追加历史 记录本地 user 写入入口与 Codex echo 过滤入口必须分离 --- docs/project-memory/shared-memory/decision-log.md | 6 ++++++ .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 470f41c17..0ebcb4186 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7949,6 +7949,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。 - 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。 +## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显 + +- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 +- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 + ## 2026-08-31 AGC 错误报告与诊断上传 - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8e886e8a4..834ed4264 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -226,7 +226,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 -- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。 -- 2.52.0 From 928fc6ffbc9947640a7f08dfd4ef5f9872dfba79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 7 Sep 2026 15:41:04 +0800 Subject: [PATCH 31/32] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20DirectProject=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E9=A2=84=E5=86=99=E4=B8=8E?= =?UTF-8?q?=E5=9B=9E=E6=98=BE=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 turn/start 前幂等写入 AGC 规范化 user item 过滤 Codex userMessage 与 raw role=user 回显,保留 assistant/tool/partial 落盘 补充用户消息来源隔离回归测试 --- .../src-tauri/src/agent/codex_app_server.rs | 33 +++++++-- .../src/agent/direct_project_history.rs | 72 ++++++++++++++++++- .../src-tauri/src/agent/direct_runtime.rs | 2 +- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index a93dd519d..3cc1860d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex, Notify}; +use uuid::Uuid; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -2129,15 +2130,12 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, - _direct_client_turn_id: Option<&str>, + direct_client_turn_id: Option<&str>, 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>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; - let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; - self.wait_for_initial_client_mcp_startup().await; - let thread_id = thread_lease.thread_id.clone(); let mut request = request; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -2147,6 +2145,21 @@ impl CodexAppServerConnection { "DirectProject 用户消息不能为空".to_string(), )); } + if let Some(client_turn_id) = direct_client_turn_id { + let user_item = direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + append_direct_project_user_message_at(history_root, &user_item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + } + } + let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; + self.wait_for_initial_client_mcp_startup().await; + let thread_id = thread_lease.thread_id.clone(); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { let items = match read_direct_project_history_items_at(history_root) { Ok(items) => items, @@ -2203,7 +2216,7 @@ impl CodexAppServerConnection { model, &self.inner.workspace_path, self.inner.workspace_mode, - _direct_client_turn_id, + direct_client_turn_id, ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -3288,13 +3301,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); + let generated_client_turn_id; + let effective_client_turn_id = match client_turn_id { + Some(client_turn_id) => Some(client_turn_id), + None => { + generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4()); + Some(generated_client_turn_id.as_str()) + } + }; connection .run_turn_with_direct_observer_and_history( &snapshot, &config.llm, request, Some(&codex_root), - client_turn_id, + effective_client_turn_id, None, observer, audit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 6919a89e3..42777dcfa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -178,11 +178,32 @@ fn direct_project_history_item_from_line( pub(crate) fn append_direct_project_history_item_at( root: &Path, item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, false) +} + +/// Appends a user message authored by AGC itself. Codex response items use +/// the default path above, which deliberately ignores echoed user messages; +/// this explicit entry point keeps the two sources distinct. +pub(crate) fn append_direct_project_user_message_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, true) +} + +fn append_direct_project_history_item_at_with_user_policy( + root: &Path, + item: &Value, + allow_user_item: bool, ) -> Result<(), String> { enforce_project_permission_policy(root, "conversation.write")?; if is_direct_project_internal_context_item(item) { return Ok(()); } + if !allow_user_item && is_direct_project_codex_user_item(item) { + return Ok(()); + } let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; let path = history_path(root); let history_exists = @@ -203,6 +224,19 @@ pub(crate) fn append_direct_project_history_item_at( append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } +fn is_direct_project_codex_user_item(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) == Some("userMessage") { + return true; + } + if item.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + !item + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user")) +} + pub(crate) fn direct_project_local_message_item( role: &str, content: &str, @@ -321,7 +355,7 @@ pub(crate) fn read_direct_project_chat_history_at( #[cfg(test)] mod tests { use super::{ - append_direct_project_history_item_at, history_path, + append_direct_project_history_item_at, append_direct_project_user_message_at, history_path, is_direct_project_internal_context_item, read_direct_project_history_items_at, }; use serde_json::json; @@ -394,4 +428,40 @@ mod tests { read_direct_project_history_items_at(root.path()).expect("read repaired history"); assert_eq!(items, vec![item]); } + + #[test] + fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤") + .expect("init project"); + let user = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "userMessage", + "id": "codex-user-item-1", + "clientId": "turn-0001", + "content": [{"type": "text", "text": "请创建菜单"}] + }), + ) + .expect("ignore Codex echo"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "message", + "role": "user", + "id": "codex-raw-user-item-1", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }), + ) + .expect("ignore raw Codex user echo"); + let items = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items, vec![user]); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 679908d4c..2dd661a36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4241,7 +4241,7 @@ fn persist_direct_codex_user_prompt_at( prompt, Some(&format!("direct-codex:{client_turn_id}:user")), )?; - append_direct_project_history_item_at(root, &item) + append_direct_project_user_message_at(root, &item) } #[cfg(test)] -- 2.52.0 From 4508b6aacaba0292a54331d217d103e1c71606c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 8 Sep 2026 10:38:06 +0800 Subject: [PATCH 32/32] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E5=A4=B1=E8=B4=A5=E5=AF=B9=E8=AF=9D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 DirectProject 持久化鉴权失败消息与当前用户可见文案一致。 --- apps/ai-game-creator-shell/tests/appSurface/home.suite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 4dc858765..ad1c1bb67 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2290,7 +2290,7 @@ export function registerHomeProjectCreationTests() { { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', - content: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', + content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', messageId: `direct-codex:${clientTurnId}:assistant`, updatedAt: 2, }, -- 2.52.0