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 304f6af6a..112e0dd8f 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 @@ -30,6 +30,9 @@ const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); +const DIRECT_CODEX_INTERMEDIATE_TEXT_MAX_CHARS: usize = 240; +const DIRECT_CODEX_INTERMEDIATE_TEXT_MIN_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(120); const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\""; const DIRECT_CODEX_SHELL_ENVIRONMENT_EXCLUDE: &str = "shell_environment_policy.exclude=[\"*KEY*\",\"*SECRET*\",\"*TOKEN*\",\"*PASSWORD*\",\"*CREDENTIAL*\",\"*PROXY*\",\"*COOKIE*\",\"GENARRATIVE_AGC_TOOL_BRIDGE_URL\",\"AGC_CONTROLLED_WEB_SEARCH_ENABLED\"]"; @@ -97,11 +100,15 @@ impl CodexAppServerCredential { ) -> Option<(&'a str, &'a str)> { match self { Self::PlatformSession { .. } => None, + #[cfg(test)] Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), + #[cfg(test)] Self::AuthBridge { api_key, .. } => api_key .as_deref() .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), + #[cfg(not(test))] + Self::AppDataKey { .. } | Self::AuthBridge { .. } => None, } } } @@ -293,12 +300,36 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure( || detail.contains("http 403") } +fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points( + error: &serde_json::Value, +) -> bool { + let Some(error) = error.as_object() else { + return false; + }; + let detail = ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .collect::>() + .join(" ") + .to_ascii_lowercase(); + detail.contains("泥点余额不足") + || detail.contains("可消费泥点不足") + || detail.contains("insufficient_mud_points") + || detail.contains("insufficient-mud-points") +} + fn game_creator_codex_app_server_failed_turn_error( turn: &serde_json::Value, ) -> platform_llm::LlmError { let Some(error) = turn.get("error").filter(|error| !error.is_null()) else { return game_creator_codex_app_server_error_kind("other"); }; + if game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(error) { + return platform_llm::LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".to_string(), + }; + } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { return game_creator_codex_app_server_error_kind("unauthorized"); } @@ -382,6 +413,7 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { AgentMessageDelta(String), + IntermediateText(String), Activity(&'static str), Item { completed: bool, @@ -394,6 +426,7 @@ enum CodexTurnEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), + IntermediateText(String), Activity(&'static str), } @@ -491,16 +524,114 @@ fn update_active_direct_mcp_tool_calls( fn direct_codex_safe_activity_for_item(item_type: &str) -> &'static str { match item_type { - "fileChange" => "file-change", - "commandExecution" => "validation", + "fileChange" => "file-write", + "commandExecution" => "command-exec", "mcpToolCall" => "controlled-tool", - "contextCompaction" | "webSearch" => "project-inspection", + "contextCompaction" => "context-compaction", + "webSearch" => "web-search", "agentMessage" => "response-finalization", - "userMessage" | "plan" | "reasoning" => "understanding", - _ => "understanding", + "userMessage" | "plan" | "reasoning" => "preparing", + _ => "preparing", } } +fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'static str { + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if item_type == "commandExecution" { + let command = item + .get("command") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if command.contains("game.static_smoke") + || command.contains("preview.validate") + || command.contains("verify") + || command.contains("test") + { + return "game-verify"; + } + return "command-exec"; + } + if item_type == "mcpToolCall" { + let tool = item + .get("tool") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if tool.contains("read") || tool.contains("list") || tool.contains("import") { + return "file-read"; + } + if tool.contains("write") || tool.contains("edit") || tool.contains("remove") { + return "file-write"; + } + if tool.contains("playtest") || tool.contains("verify") { + return "game-verify"; + } + if tool.contains("search") { + return "web-search"; + } + } + direct_codex_safe_activity_for_item(item_type) +} + +/// Project a started Codex item into a short user-visible progress line. +/// Codex app-server 0.147/0.149 only pushes structural item/started events +/// (with the concrete command/tool/path) while tools run; it does not push +/// plan/reasoning text deltas. Showing what the agent is actually doing is +/// the only reliable way to make the execution phase feel alive. +fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { + const MAX_ITEM_TEXT_CHARS: usize = 240; + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let text = match item_type { + "mcpToolCall" => { + let tool = item + .get("tool") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("工具"); + match item + .pointer("/arguments/path") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + Some(path) => format!("正在调用 {tool}:{path}"), + None => format!("正在调用 {tool}"), + } + } + "commandExecution" => { + let command = item + .get("command") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()); + match command { + Some(command) => format!("正在执行:{command}"), + None => "正在执行命令".to_string(), + } + } + "fileChange" => { + let path = item + .pointer("/changes/0/path") + .and_then(serde_json::Value::as_str) + .or_else(|| item.get("path").and_then(serde_json::Value::as_str)) + .filter(|value| !value.trim().is_empty()); + match path { + Some(path) => format!("正在修改:{path}"), + None => "正在修改项目文件".to_string(), + } + } + "webSearch" => "正在联网搜索".to_string(), + "contextCompaction" => "正在整理上下文".to_string(), + _ => return None, + }; + Some(text.chars().take(MAX_ITEM_TEXT_CHARS).collect::()) +} + fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static str> { match method { "turn/started" @@ -508,17 +639,60 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static | "item/plan/delta" | "item/reasoning/summaryTextDelta" | "item/reasoning/summaryPartAdded" - | "item/reasoning/textDelta" => Some("understanding"), + | "item/reasoning/textDelta" => Some("preparing"), "item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"), - "item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-change"), + "item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-write"), "command/exec/outputDelta" | "process/outputDelta" - | "item/commandExecution/outputDelta" - | "model/verification" => Some("validation"), + | "item/commandExecution/outputDelta" => Some("command-exec"), + "model/verification" => Some("game-verify"), _ => None, } } +fn direct_codex_intermediate_text_for_notification( + method: &str, + params: &serde_json::Value, +) -> Option { + let value = match method { + "turn/plan/updated" => params + .get("explanation") + .and_then(serde_json::Value::as_str), + "item/reasoning/summaryTextDelta" + | "item/reasoning/summaryPartAdded" + | "item/reasoning/textDelta" => params.get("delta").and_then(serde_json::Value::as_str), + "item/mcpToolCall/progress" => params.get("message").and_then(serde_json::Value::as_str), + "item/fileChange/outputDelta" => params.get("delta").and_then(serde_json::Value::as_str), + _ => None, + }?; + let value = value.trim(); + if value.is_empty() { + return None; + } + Some( + value + .chars() + .take(DIRECT_CODEX_INTERMEDIATE_TEXT_MAX_CHARS) + .collect::(), + ) +} + +fn should_emit_direct_codex_intermediate_text( + last_text: &mut Option<(String, std::time::Instant)>, + text: &str, +) -> bool { + let now = std::time::Instant::now(); + if last_text.as_ref().is_some_and(|(previous, observed_at)| { + previous == text + && now.saturating_duration_since(*observed_at) + < DIRECT_CODEX_INTERMEDIATE_TEXT_MIN_INTERVAL + }) { + return false; + } + *last_text = Some((text.to_string(), now)); + true +} + fn should_emit_direct_codex_activity( last_activity: &mut Option<(&'static str, std::time::Instant)>, activity: &'static str, @@ -966,6 +1140,7 @@ fn game_creator_codex_app_server_interaction_response( } } +#[cfg(test)] fn find_game_creator_codex_auth_path() -> Option { std::env::var_os("CODEX_HOME") .map(std::path::PathBuf::from) @@ -980,6 +1155,7 @@ fn find_game_creator_codex_auth_path() -> Option { .filter(|path| path.is_file()) } +#[cfg(test)] fn read_game_creator_codex_auth_bridge( source_auth: &std::path::Path, ) -> Result { @@ -1022,6 +1198,7 @@ fn read_game_creator_codex_auth_bridge( }) } +#[cfg(test)] fn resolve_game_creator_codex_app_server_credential( llm: &GameCreatorLlmConfig, ) -> Result { @@ -1240,13 +1417,17 @@ fn configure_game_creator_codex_app_server_command_for_mode( command.arg("--disable").arg("unified_exec"); } } - if provider_proxy.is_some() || !llm.api_key.trim().is_empty() { + #[cfg(test)] + let legacy_api_key = llm.api_key.trim(); + #[cfg(not(test))] + let legacy_api_key = ""; + if provider_proxy.is_some() || !legacy_api_key.is_empty() { let provider_base_url = provider_proxy .map(CodexProviderProxy::base_url) .unwrap_or_else(|| llm.base_url.trim_end_matches('/')); let provider_token = provider_proxy .map(CodexProviderProxy::downstream_bearer_token) - .unwrap_or_else(|| llm.api_key.trim()); + .unwrap_or(legacy_api_key); command .arg("-c") .arg(format!( @@ -1370,7 +1551,14 @@ impl CodexAppServerConnection { access_token: session.access_token, } } else { - resolve_game_creator_codex_app_server_credential(llm)? + #[cfg(test)] + { + resolve_game_creator_codex_app_server_credential(llm)? + } + #[cfg(not(test))] + { + unreachable!("real AGC builds always use the platform session route") + } }; let key = game_creator_codex_app_server_pool_key( &effective_llm, @@ -1442,6 +1630,7 @@ impl CodexAppServerConnection { Ok(connection) } + #[cfg(test)] async fn spawn( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, @@ -1451,6 +1640,7 @@ impl CodexAppServerConnection { Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await } + #[cfg(test)] async fn spawn_with_executable( llm: &GameCreatorLlmConfig, executable: &std::ffi::OsStr, @@ -1459,6 +1649,7 @@ impl CodexAppServerConnection { Self::spawn_with_executable_and_credential(llm, &credential, executable).await } + #[cfg(test)] async fn spawn_with_executable_and_credential( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, @@ -1634,6 +1825,14 @@ impl CodexAppServerConnection { .env("APPDATA", &isolated_app_data) .env("LOCALAPPDATA", &isolated_local_app_data) .env_remove("CODEX_API_KEY"); + // Codex app-server must reach the local provider proxy and local MCP + // endpoints directly. A host-level HTTP proxy breaks loopback SSE + // connections, so loopback stays outside every proxy scope even when + // the parent process exported proxy variables. + for proxy_key in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] { + command.env_remove(proxy_key); + } + command.env("NO_PROXY", "localhost,127.0.0.1,::1"); if let Some(provider_proxy) = provider_proxy.as_ref() { // `game_creator_codex_cli_minimal_environment` clears every env // configured above. Restore only the broker's connection-scoped @@ -1642,7 +1841,9 @@ impl CodexAppServerConnection { GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_proxy.downstream_bearer_token(), ); - } else if credential.uses_app_data_key() { + } + #[cfg(test)] + if provider_proxy.is_none() && credential.uses_app_data_key() { command.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, &llm.api_key); } configure_game_creator_codex_cli_process(&mut command); @@ -1875,7 +2076,11 @@ impl CodexAppServerConnection { &self.inner.workspace_path, self.inner.workspace_mode, base_instructions, - !llm.api_key.trim().is_empty(), + // The official route clears `llm.api_key` before spawning Codex, + // but still has a connection-scoped provider proxy. Select the + // configured provider from that proxy rather than falling back to + // Codex's default provider in Debug builds. + self.inner._provider_proxy.is_some() || !llm.api_key.trim().is_empty(), ); let result = self .request("thread/start", params) @@ -2114,6 +2319,11 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::IntermediateText(text)) => { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::IntermediateText(text)); + } + } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity(activity)); @@ -2127,8 +2337,19 @@ impl CodexAppServerConnection { .unwrap_or_default(); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity( - direct_codex_safe_activity_for_item(item_type), + direct_codex_safe_activity_for_item_value(item), )); + // item/started 在工具真正开始执行时到达,携带 + // 具体命令/工具/路径。把它投影为可见中间态文本, + // 让执行期间聊天窗口显示“正在做什么”,而不是只 + // 有活动状态来回跳动。completed 事件不再重复。 + if !completed { + if let Some(text) = direct_codex_item_intermediate_text(item) { + observer(DirectCodexTurnObservation::IntermediateText( + text, + )); + } + } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject @@ -2375,6 +2596,8 @@ async fn read_game_creator_codex_app_server_stdout( let mut reader = BufReader::new(stdout); let mut last_direct_activity_by_turn = HashMap::>::new(); + let mut last_direct_intermediate_text_by_turn = + HashMap::>::new(); loop { let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await { Ok(Some(buffer)) => buffer, @@ -2556,10 +2779,18 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_str) .unwrap_or_default(); let safe_activity = direct_codex_safe_activity_for_notification(method); + let intermediate_text = direct_codex_intermediate_text_for_notification( + method, + &message + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null), + ); if !matches!( method, "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" ) && safe_activity.is_none() + && intermediate_text.is_none() { continue; } @@ -2587,7 +2818,17 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(activity) = safe_activity { + if let Some(text) = intermediate_text.as_deref() { + let last_text = last_direct_intermediate_text_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_intermediate_text(last_text, text) { + continue; + } + } + let event = if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else if let Some(activity) = safe_activity { CodexTurnEvent::Activity(activity) } else { match method { @@ -2610,6 +2851,7 @@ async fn read_game_creator_codex_app_server_stdout( }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); + last_direct_intermediate_text_by_turn.remove(&turn_id); inner.turns.lock().await.remove(&turn_id) } else { inner.turns.lock().await.get(&turn_id).cloned() @@ -3077,10 +3319,14 @@ mod tests { #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ - "understanding", - "project-inspection", - "file-change", - "validation", + "preparing", + "file-read", + "file-write", + "game-verify", + "command-exec", + "controlled-tool", + "web-search", + "context-compaction", "response-finalization", ]; for item_type in [ @@ -3102,6 +3348,47 @@ mod tests { } } + #[test] + fn direct_item_intermediate_text_projects_started_work_into_short_visible_lines() { + let mcp = serde_json::json!({ + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "game/index.html" } + }); + assert_eq!( + direct_codex_item_intermediate_text(&mcp).as_deref(), + Some("正在调用 agc_write_file:game/index.html") + ); + + let command = serde_json::json!({ + "type": "commandExecution", + "command": "npm run build" + }); + assert_eq!( + direct_codex_item_intermediate_text(&command).as_deref(), + Some("正在执行:npm run build") + ); + assert_eq!( + direct_codex_safe_activity_for_item_value(&serde_json::json!({ + "type": "commandExecution", + "command": "preview.validate" + })), + "game-verify" + ); + + let file_change = serde_json::json!({ + "type": "fileChange", + "changes": [{ "path": "game/player.gd" }] + }); + assert_eq!( + direct_codex_item_intermediate_text(&file_change).as_deref(), + Some("正在修改:game/player.gd") + ); + + let reasoning = serde_json::json!({ "type": "reasoning" }); + assert_eq!(direct_codex_item_intermediate_text(&reasoning), None); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), @@ -3692,6 +3979,43 @@ mod tests { } } + #[test] + fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() { + for detail in [ + "泥点余额不足", + "可消费泥点不足:需要 31,扣除退款占用后可用 11", + ] { + let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ + "status": "failed", + "error": { + "message": detail, + "codexErrorInfo": "other" + } + })); + assert_eq!( + error, + platform_llm::LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".to_string(), + } + ); + } + let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ + "status": "failed", + "error": { + "code": "insufficient_mud_points", + "codexErrorInfo": "other" + } + })); + assert_eq!( + error, + platform_llm::LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".to_string(), + } + ); + } + #[test] fn codex_app_server_rejects_non_responses_key_mapping() { let mut llm = test_llm(); @@ -4319,7 +4643,7 @@ while IFS= read -r line; do :; done assert_eq!(streamed, "{\"toolCalls\":"); assert_eq!( observations.first(), - Some(&DirectCodexTurnObservation::Activity("understanding")), + Some(&DirectCodexTurnObservation::Activity("preparing")), "turn/started must produce safe activity before terminal completion" ); let delta_index = observations @@ -4340,14 +4664,23 @@ while IFS= read -r line; do :; done >= 4, "real long-tool protocol activity must be visible before final answer delta" ); - for expected in ["file-change", "validation"] { - assert!(observations.contains(&DirectCodexTurnObservation::Activity(expected))); - } + assert!(observations.iter().any(|observation| { + matches!( + observation, + DirectCodexTurnObservation::Activity("file-write") + ) + })); + assert!(observations.iter().any(|observation| { + matches!( + observation, + DirectCodexTurnObservation::Activity("command-exec") + ) + })); assert!( observations .iter() .filter(|observation| { - **observation == DirectCodexTurnObservation::Activity("understanding") + **observation == DirectCodexTurnObservation::Activity("preparing") }) .count() >= 2, 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 34cb07418..ad1c292f6 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 @@ -1741,6 +1741,9 @@ impl DirectCodexTurnFailure { fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { let normalized = error.to_ascii_lowercase(); + if direct_codex_error_is_mud_points_insufficient(error) { + return "泥点余额不足,请充值后发送“继续”"; + } if private_external_editor_credentials_storage_preparation_failed(error) { return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; } @@ -1786,6 +1789,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st } fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { + if direct_codex_error_is_mud_points_insufficient(error) { + return Some("泥点余额不足"); + } if private_external_editor_credentials_storage_preparation_failed(error) { return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); } @@ -1796,6 +1802,9 @@ fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { } fn direct_codex_failure_is_retryable(error: &str) -> bool { + if direct_codex_error_is_mud_points_insufficient(error) { + return false; + } ![ "private-external-editor-credential-storage-preparation-failed", "private-external-editor-credential-persistence-failed", @@ -1809,6 +1818,15 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool { .any(|marker| error.contains(marker)) } +fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + error.contains("泥点余额不足") + || error.contains("可消费泥点不足") + || normalized.contains("kind=mud-points-insufficient") + || normalized.contains("insufficient_mud_points") + || normalized.contains("insufficient-mud-points") +} + fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { let summary = direct_codex_failure_public_summary(&failure.error) .map(str::to_string) @@ -1907,8 +1925,7 @@ fn direct_taonier_art_asset_identity( return None; } let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; - if !std::path::Path::new(&asset_path).is_file() - { + if !std::path::Path::new(&asset_path).is_file() { return None; } let bytes = std::fs::read(asset_path).ok()?; @@ -3645,7 +3662,7 @@ fn sync_direct_codex_project_outputs_at( } /// Project Codex text into the only form that may cross the DirectProject UI -/// boundary. The app-server stream can contain reasoning blocks, URLs, +/// boundary. The app-server stream can contain reasoning blocks, URLs, /// credentials, or host paths before the final reply is known; those values /// must never be emitted as an intermediate chat message or persisted as the /// user-visible assistant turn. @@ -3877,7 +3894,7 @@ async fn run_direct_game_creator_turn_inner( ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("understanding"), None); + emitter.emit("running", Some("preparing"), None); } let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) @@ -3889,8 +3906,10 @@ async fn run_direct_game_creator_turn_inner( .map_err(|error| { DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; + let live_streamed_text = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reply = if let Some(emitter) = turn_emitter { let emitter = emitter.clone(); + let live_streamed_text_for_observer = std::sync::Arc::clone(&live_streamed_text); let mut has_streamed = false; let mut latest_accumulated_text = None; let mut observer = move |observation: DirectCodexTurnObservation| match observation { @@ -3901,9 +3920,22 @@ async fn run_direct_game_creator_turn_inner( return; } has_streamed = true; + live_streamed_text_for_observer.store(true, std::sync::atomic::Ordering::Relaxed); latest_accumulated_text = visible_text.clone(); emitter.emit("streaming", None, visible_text); } + DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + let visible_text = if stream_enabled { + project_direct_codex_visible_text(root, &intermediate_text) + } else { + None + }; + if let Some(visible_text) = visible_text { + has_streamed = true; + latest_accumulated_text = Some(visible_text.clone()); + emitter.emit("streaming", None, Some(visible_text)); + } + } DirectCodexTurnObservation::Activity(activity) => { emitter.emit( if has_streamed { "streaming" } else { "running" }, @@ -3938,6 +3970,31 @@ async fn run_direct_game_creator_turn_inner( ) })?; if let Some(emitter) = turn_emitter { + // The Router/Responses upstream frequently buffers the whole agent + // reply and only delivers it with the terminal item, so real + // agentMessage deltas never arrive while tools run. When no live + // delta reached the UI, replay the final reply as a bounded typewriter + // stream so the chat shows progressive text instead of one jump from + // activity status to the completed message. + if stream_enabled + && !live_streamed_text.load(std::sync::atomic::Ordering::Relaxed) + && !visible_reply.is_empty() + { + const TYPEWRITER_CHUNK_CHARS: usize = 24; + const TYPEWRITER_CHUNK_DELAY_MS: u64 = 40; + let text = visible_reply.as_str(); + let mut offset = 0usize; + while offset < text.len() { + let mut end = (offset + TYPEWRITER_CHUNK_CHARS).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + emitter.emit("streaming", None, Some(text[..end].trim_end().to_string())); + offset = end; + tokio::time::sleep(std::time::Duration::from_millis(TYPEWRITER_CHUNK_DELAY_MS)) + .await; + } + } emitter.emit( "finalizing", Some("response-finalization"), @@ -3953,7 +4010,7 @@ async fn run_direct_game_creator_turn_inner( if let Some(emitter) = turn_emitter { emitter.emit( "finalizing", - Some("file-change"), + Some("file-write"), Some(visible_reply.clone()), ); } @@ -4331,6 +4388,21 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + #[test] + fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() { + let error = "direct-codex-failure:v1 summary=泥点余额不足"; + assert!(direct_codex_error_is_mud_points_insufficient(error)); + assert_eq!( + direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), + "泥点余额不足,请充值后发送“继续”" + ); + assert_eq!( + direct_codex_failure_public_summary(error), + Some("泥点余额不足") + ); + assert!(!direct_codex_failure_is_retryable(error)); + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!(