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)?;