diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 35c52a8bc..138ae6871 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 @@ -3548,12 +3548,20 @@ impl CodexAppServerConnection { let direct_turn_user_item_id = direct_persisted_user_item .as_ref() .and_then(direct_thread_item_identity); + // 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何 + // 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能 + // 永远停在"还在跑"。 + let mut direct_turn_failure_guard: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { append_direct_thread_event( &direct_thread_id, DirectThreadEvent::turn_started(direct_turn_started_at_ms) .with_user_item_id(direct_turn_user_item_id.as_deref()), ); + direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm( + direct_thread_id.clone(), + direct_turn_user_item_id.clone(), + )); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 @@ -4061,11 +4069,30 @@ impl CodexAppServerConnection { .map(|(_, at)| *at) .unwrap_or_else(direct_tool_call_now_ms) }; - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_completed(status, completed_at) - .with_user_item_id(direct_turn_user_item_id.as_deref()), + // 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主 + // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 + // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 + // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 + let failure = direct_turn_failure( + &status, + collect_result.as_ref().map(String::as_str), + history_root, ); + match failure { + Some(failure) => append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed_failed(failure, completed_at) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ), + None => append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed(status, completed_at) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ), + }; + if let Some(guard) = direct_turn_failure_guard.as_mut() { + guard.disarm(); + } } let text = collect_result?; guard.armed = false;