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 6e1e985b0..68b6472af 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 @@ -1417,11 +1417,6 @@ fn trust_isolated_game_creator_codex_workspace( } impl CodexAppServerConnection { - async fn has_thread_for(&self, snapshot: &AgentRuntimeProviderRequestSnapshot) -> bool { - let key = CodexNodeThreadKey::from(snapshot); - self.inner.threads.lock().await.contains_key(&key) - } - async fn acquire( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -1948,17 +1943,23 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, request: &LlmRunRequest, llm: &GameCreatorLlmConfig, - ) -> Result { + ) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> { let key = CodexNodeThreadKey::from(snapshot); let mut threads = self.inner.threads.lock().await; + // The caller holds `turn_gate` while invoking this method. Returning + // the creation bit from the same threads lock keeps the replay + // decision atomic with thread reuse/creation. if let Some(entry) = threads.get_mut(&key) { entry.active_uses = entry.active_uses.saturating_add(1); entry.last_used = next_game_creator_codex_app_server_usage_tick(); - return Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id: entry.thread_id.clone(), - }); + return Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }, + false, + )); } if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { let evict_key = threads @@ -2020,11 +2021,14 @@ impl CodexAppServerConnection { active_uses: 1, }, ); - Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id, - }) + Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }, + true, + )) } async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { @@ -2075,6 +2079,7 @@ impl CodexAppServerConnection { snapshot, llm, request, + None, on_agent_message_delta, None, None, @@ -2087,14 +2092,37 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, request: LlmRunRequest, + 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 = self.thread_for(snapshot, &request, llm).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; + if thread_created && self.inner.workspace_mode.uses_direct_conversation() { + // DirectProject intentionally replays the complete append-only + // project history. AGC owns that durable fact source; keeping + // the replay lossless is the product contract even though a + // future context-budget policy may need to change this choice. + let current_prompt = direct_codex_current_user_prompt(&request).to_string(); + let history_prompt = build_direct_codex_history_prompt( + &self.inner.workspace_path, + direct_client_turn_id.unwrap_or("__none__"), + ¤t_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) } else { @@ -3145,22 +3173,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|error| error.to_string())?; - let user_prompt = if connection.has_thread_for(&snapshot).await { - user_prompt - } else { - build_direct_codex_history_prompt( - &codex_root, - client_turn_id.unwrap_or("__none__"), - &user_prompt, - )? - }; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) + .run_turn_with_direct_observer( + &snapshot, + &config.llm, + request, + client_turn_id, + None, + observer, + audit, + ) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -3173,6 +3200,9 @@ pub(crate) fn build_direct_codex_history_prompt( ) -> 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 mut lines = conversation .messages .iter() @@ -4622,6 +4652,7 @@ while IFS= read -r line; do :; done &test_snapshot(), &llm, tool_request(), + None, Some(&mut on_delta), Some(&mut observer), None,