From bf8f96c115062c14aed13c51dd60770a0eb7296d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 22:15:57 +0800 Subject: [PATCH] =?UTF-8?q?DirectProject=20=E5=9B=9E=E5=90=88=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E8=A1=A5=E9=BD=90=E7=94=A8=E6=88=B7=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E4=B8=8E=E7=BB=88=E6=80=81=E5=85=9C=E5=BA=95=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20direct=5Fthread=5Fid=5Ffor=5Fproject=EF=BC=9A?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E3=80=81=E5=9B=9E=E5=90=88=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=86=99=E5=85=A5=E4=B8=8E=E5=85=9C=E5=BA=95=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E4=B8=80=E4=B8=AA=E7=BA=BF=E7=A8=8B=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20-=20=E8=AE=A2=E9=98=85=E5=91=BD=E4=BB=A4=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=90=8C=E4=B8=80=E4=B8=AA=E8=BA=AB=E4=BB=BD=E5=87=BD?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E5=88=A0=E6=8E=89=E5=91=BD=E4=BB=A4=E5=B1=82?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E9=82=A3=E5=A5=97=E8=B7=AF=E5=BE=84=E5=BD=92?= =?UTF-8?q?=E4=B8=80=20-=20=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E5=90=8E=E7=AB=8B=E5=88=BB=E4=BD=9C=E4=B8=BA=20item.c?= =?UTF-8?q?ompleted=20=E4=B8=8B=E5=8F=91=EF=BC=8C=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=94=A8=E5=90=8C=E4=B8=80=E4=B8=AA=20itemId=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B9=90=E8=A7=82=E6=B0=94=E6=B3=A1=E4=B8=8E=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=9D=A1=E7=9B=AE=20-=20=E9=87=87=E9=9B=86=E7=BB=88?= =?UTF-8?q?=E6=80=81=E4=BA=8B=E4=BB=B6=E6=98=AF=E5=90=A6=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=EF=BC=8C=E7=BC=BA=E5=A4=B1=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E6=88=90=E5=8A=9F=20/=20=E5=A4=B1=E8=B4=A5=E8=A1=A5=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=20turn.completed=EF=BC=8C=E9=81=BF=E5=85=8D=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=B0=B8=E8=BF=9C=E5=81=9C=E5=9C=A8=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=20-=20=E5=8F=96=E6=B6=88=E5=9B=9E=E5=90=88=E7=9A=84?= =?UTF-8?q?=20Stale=20=E5=88=86=E6=94=AF=E8=A1=A5=E4=B8=80=E6=9D=A1=20turn?= =?UTF-8?q?.completed(aborted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent.rs | 3 +- .../direct_project_identity.rs | 18 +++++++ .../src/agent/codex_app_server/mod.rs | 50 +++++++++++++++++-- .../src-tauri/src/commands.rs | 13 +---- 4 files changed, 69 insertions(+), 15 deletions(-) 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 5c8f7d58a..ced9b0a98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -41,7 +41,8 @@ use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, - direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, + direct_thread_id_for_project, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs index 901467454..fd2068ae9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -20,6 +20,24 @@ pub(crate) fn direct_codex_canonical_project_identity( )) } +/// 项目根目录在 Thread Manager 里的线程身份。 +/// +/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到 +/// 一个永不产生事件的空线程;因此这里统一做一次权威路径归一。归一失败(例如 manifest +/// 暂时读不到)时退回调用方给的路径,至少保持同一入口同一结果。 +pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String { + let Ok((canonical_root, _)) = direct_codex_canonical_project_identity(root) else { + return root.to_string_lossy().into_owned(); + }; + canonical_root + .to_str() + .and_then(|value| value.strip_prefix(r"\\?\")) + .map(std::path::Path::new) + .unwrap_or(canonical_root.as_path()) + .to_string_lossy() + .into_owned() +} + pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { #[cfg(unix)] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index f47ec3b29..3cb7bc305 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -13,8 +13,11 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; mod direct_project_identity; -pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands; use direct_project_identity::*; +pub(crate) use direct_project_identity::{ + direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands, + direct_thread_id_for_project, +}; 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"; @@ -2821,6 +2824,9 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); + // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发, + // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。 + let mut direct_persisted_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2844,6 +2850,7 @@ impl CodexAppServerConnection { }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_persisted_user_item = Some(user_item); } } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; @@ -2955,9 +2962,17 @@ impl CodexAppServerConnection { } }; turn_start_guard.armed = false; - let direct_thread_id = history_root.to_string_lossy().into_owned(); + let direct_thread_id = direct_thread_id_for_project(history_root); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); + if let Some(user_item) = direct_persisted_user_item.as_ref() { + if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item), + ); + } + } } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); @@ -2975,6 +2990,9 @@ impl CodexAppServerConnection { game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms); let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms); + // 记录 app-server 是否已经给出终态:`turn/completed` 只在收到被动 terminal + // 事件时下发,超时 / 传输中断 / 协议错误都走不到那里。 + let mut terminal_recorded = false; let collect = async { let mut final_text = None; let mut streamed_text = String::new(); @@ -3263,6 +3281,7 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject && matches!(status, "completed" | "interrupted" | "failed") { + terminal_recorded = true; append_direct_thread_event( &direct_thread_id, DirectThreadEvent::turn_completed(status.to_string()), @@ -3306,7 +3325,26 @@ impl CodexAppServerConnection { } } }; - let text = match collect.await { + let collect_result = collect.await; + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + && !terminal_recorded + { + // 兜底终态:没有这条事件,前端的"最新回合是否在跑"会一直停在运行中。 + // 事件日志本身不完美(见 `ThreadState::lifecycle_anchor` 的 TODO), + // 但"这一轮有没有结束"必须有终态事件。 + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed( + if collect_result.is_ok() { + "completed" + } else { + "failed" + } + .to_string(), + ), + ); + } + let text = match collect_result { Ok(text) => text, Err(error) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3553,6 +3591,12 @@ pub(crate) fn cancel_direct_codex_turn_at( DirectCodexTurnCancelTarget::Stale(reason) => { let released = release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), + // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 + append_direct_thread_event( + &direct_thread_id_for_project(root), + DirectThreadEvent::turn_completed("aborted".to_string()), + ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( 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 eff140194..aca21d7f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,8 +1,5 @@ use super::*; -use crate::agent::{ - direct_codex_canonical_project_identity, read_direct_project_chat_history_at, - read_direct_project_last_item_id_at, -}; +use crate::agent::{read_direct_project_chat_history_at, read_direct_project_last_item_id_at}; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -5320,13 +5317,7 @@ pub(crate) async fn subscribe_direct_project_thread( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (canonical_root, _) = direct_codex_canonical_project_identity(root)?; - let thread_root = canonical_root - .to_str() - .and_then(|value| value.strip_prefix("\\\\?\\")) - .map(Path::new) - .unwrap_or(canonical_root.as_path()); - let thread_id = thread_root.to_string_lossy().into_owned(); + let thread_id = direct_thread_id_for_project(root); let mut bootstrap = subscribe_direct_thread(&thread_id); if bootstrap.last_completed_item_id.is_none() { bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;