diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 8ef63ae8d..0149d953a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -107,11 +107,13 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'append_direct_project_conversation_message', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', + 'read_direct_project_conversation', 'stop_local_game_preview_if_matches', 'start_game_creator_external_mcp', 'stop_game_creator_external_mcp', 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 bb0f70aee..cc37945ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,8 @@ mod codex_cli; mod codex_provider_proxy; mod direct_codex_attachments; mod direct_codex_audit; +mod direct_project_history; +mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -38,6 +40,8 @@ pub(crate) use codex_cli::{ pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; +pub(crate) use direct_project_history::*; +pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; 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 2410c4adf..b31ae894a 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 @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex, Notify}; +use uuid::Uuid; 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"; @@ -443,13 +444,17 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { - AgentMessageDelta(String), + AgentMessageDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { completed: bool, params: serde_json::Value, }, + RawItem(serde_json::Value), Terminal(serde_json::Value), TransportClosed(String), } @@ -869,11 +874,28 @@ fn direct_codex_notification_event( .get("delta") .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()) - .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())), + .map(|delta| { + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + }), "item/started" | "item/completed" => Some(CodexTurnEvent::Item { completed: method == "item/completed", params: params.clone(), }), + "rawResponseItem/completed" => Some(CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + )), _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -1312,6 +1334,9 @@ fn codex_app_server_thread_start_params( params["modelProvider"] = serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + params["experimentalRawEvents"] = serde_json::Value::Bool(true); + } params } @@ -1321,6 +1346,7 @@ fn codex_app_server_turn_start_params( model: &str, workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, + client_user_message_id: Option<&str>, ) -> serde_json::Value { let approval_policy = "never"; let mut params = serde_json::json!({ @@ -1339,6 +1365,13 @@ fn codex_app_server_turn_start_params( "networkAccess": true }); } + if let Some(client_user_message_id) = client_user_message_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + params["clientUserMessageId"] = + serde_json::Value::String(client_user_message_id.to_string()); + } params } @@ -2551,32 +2584,48 @@ impl CodexAppServerConnection { mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; + let mut request = request; + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let current_prompt = direct_codex_current_user_prompt(&request).trim(); + if current_prompt.is_empty() { + return Err(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + )); + } + if let Some(client_turn_id) = direct_client_turn_id { + let user_item = direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + append_direct_project_user_message_at(history_root, &user_item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + } + } 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 owns the append-only project history in AGC. The - // replay builder derives a bounded prompt without mutating that - // durable fact source, so a new ephemeral thread can recover the - // newest contiguous context within the model budget. - let current_prompt = direct_codex_current_user_prompt(&request).to_string(); - let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - let history_prompt = build_direct_codex_history_prompt( - history_root, - direct_client_turn_id.unwrap_or("__none__"), - ¤t_prompt, - &request, - llm, - ) - .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; + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if thread_created { + let items = match read_direct_project_history_items_at(history_root) { + Ok(items) => items, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + }; + if let Err(error) = self + .request( + "thread/inject_items", + serde_json::json!({"threadId": thread_id, "items": items}), + ) + .await + { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::Transport(error)); + } } } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { @@ -2615,6 +2664,7 @@ impl CodexAppServerConnection { model, &self.inner.workspace_path, self.inner.workspace_mode, + direct_client_turn_id, ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -2659,6 +2709,7 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let mut receiver = self.register_turn(&turn_id).await; + let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { connection: self.clone(), thread_id: thread_id.clone(), @@ -2705,7 +2756,10 @@ impl CodexAppServerConnection { } }; match event { - Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + direct_project_history.observe_delta(&item_id, &delta); + } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::AccumulatedText( @@ -2725,6 +2779,28 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::IntermediateText(text)); } } + Some(CodexTurnEvent::RawItem(item)) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if item.is_null() { + return Err(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + )); + } + let history_root = history_root.to_path_buf(); + let history_item = item.clone(); + tokio::task::spawn_blocking(move || { + append_direct_project_history_item_at(&history_root, &history_item) + }) + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "DirectProject 历史落盘任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_project_history.complete_item(&item); + } + } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity(activity)); @@ -2843,7 +2919,22 @@ impl CodexAppServerConnection { } } }; - let text = collect.await?; + let text = match collect.await { + Ok(text) => text, + Err(error) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Err(persist_error) = persist_direct_project_partial_items_at( + history_root, + &mut direct_project_history, + ) { + return Err(platform_llm::LlmError::Transport(format!( + "DirectProject 异常收尾历史失败:{persist_error};原始回合错误:{error}" + ))); + } + } + return Err(error); + } + }; guard.armed = false; self.inner.turns.lock().await.remove(&turn_id); let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; @@ -3220,7 +3311,11 @@ async fn read_game_creator_codex_app_server_stdout( ); if !matches!( method, - "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + "item/agentMessage/delta" + | "item/started" + | "item/completed" + | "rawResponseItem/completed" + | "turn/completed" ) && safe_activity.is_none() && intermediate_text.is_none() { @@ -3258,14 +3353,57 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = match direct_codex_notification_event( - method, - ¶ms, - intermediate_text, - safe_activity, - ) { - Some(event) => event, - None => continue, + let event = if let Some(activity) = safe_activity { + // Preparing notifications may carry private plan/reasoning text; + // expose only the safe activity category. Other categories may + // retain their bounded, redacted intermediate text below. + if activity == "preparing" { + CodexTurnEvent::Activity(activity) + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + CodexTurnEvent::Activity(activity) + } + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + } + "item/started" | "item/completed" => CodexTurnEvent::Item { + completed: method == "item/completed", + params, + }, + "rawResponseItem/completed" => CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + ), + _ => CodexTurnEvent::Terminal(params), + } }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -3657,13 +3795,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); + let generated_client_turn_id; + let effective_client_turn_id = match client_turn_id { + Some(client_turn_id) => Some(client_turn_id), + None => { + generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4()); + Some(generated_client_turn_id.as_str()) + } + }; connection .run_turn_with_direct_observer_and_history( &snapshot, &config.llm, request, Some(&codex_root), - client_turn_id, + effective_client_turn_id, None, observer, audit, @@ -3673,155 +3819,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } -/// Builds the prompt used when a new DirectProject thread needs replay. -/// -/// The JSONL history remains immutable; only the derived prompt is bounded. -/// When the full request exceeds the replay target, the oldest contiguous -/// records are omitted and a plain `system:` marker is prepended. -pub(crate) fn build_direct_codex_history_prompt( - root: &std::path::Path, - client_turn_id: &str, - current_prompt: &str, - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, -) -> 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 lines = conversation - .messages - .iter() - .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) - .map(|message| format!("{}: {}", message.role, message.content)) - .collect::>(); - let current_line = format!("user: {}", current_prompt.trim()); - let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); - let target_budget = direct_codex_replay_target_budget(llm, base_request)?; - if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { - return Ok(full_prompt); - } - - // The persisted project conversation is immutable. We only derive a - // bounded prompt for this replay, keeping the newest contiguous records. - let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; - let mut selected_reversed = Vec::new(); - for line in lines.iter().rev() { - let mut candidate_reversed = selected_reversed.clone(); - candidate_reversed.push(line.as_str()); - let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); - let candidate_prompt = format_direct_codex_replay_prompt( - &candidate_lines, - ¤t_line, - Some(omission_marker), - ); - if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { - selected_reversed.push(line.as_str()); - } else { - break; - } - } - - let selected_lines = selected_reversed.iter().rev().copied().collect::>(); - let marked_prompt = - format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); - if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { - return Ok(marked_prompt); - } - - // If the marker itself would push the request over the target, preserve - // the current user request and omit only the marker. - let current_only_prompt = - format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); - if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { - return Ok(current_only_prompt); - } - direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) -} - -const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = - "system: Earlier conversation history was omitted due to context budget."; - -fn format_direct_codex_replay_prompt( - history_lines: &[impl AsRef], - current_line: &str, - omission_marker: Option<&str>, -) -> String { - let mut lines = Vec::with_capacity(history_lines.len() + 2); - if let Some(marker) = omission_marker { - lines.push(marker.to_string()); - } - lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); - lines.push(current_line.to_string()); - lines.join("\n") -} - -fn direct_codex_replay_target_budget( - llm: &GameCreatorLlmConfig, - request: &LlmRunRequest, -) -> Result { - const SAFETY_MARGIN_TOKENS: u64 = 4_096; - let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); - let hard_budget = llm - .context_window_tokens - .checked_sub(max_output_tokens) - .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) - .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; - Ok(llm.auto_compact_token_limit.min(hard_budget)) -} - -fn direct_codex_replay_estimate( - base_request: &LlmRunRequest, - prompt: &str, -) -> Result { - let mut request = base_request.clone(); - let user = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; - user.content = prompt.to_string(); - Ok(request) -} - -fn direct_codex_replay_prompt_fits( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, - target_budget: u64, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - if estimated > target_budget { - return Ok(false); - } - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(true) -} - -fn direct_codex_replay_validate_context_budget( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(prompt.to_string()) -} - /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3903,123 +3900,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result }) } +#[cfg(test)] +pub(crate) fn build_direct_codex_history_prompt( + root: &Path, + _client_turn_id: &str, + current_prompt: &str, + _request: &LlmRunRequest, + _llm: &GameCreatorLlmConfig, +) -> Result { + let mut lines = read_direct_project_chat_history_at(root)? + .messages + .into_iter() + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + lines.push(format!("user: {}", current_prompt.trim())); + Ok(lines.join("\n")) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn direct_history_prompt_replays_all_project_messages_in_order() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "history-project", "history").expect("init"); - for (role, content, message_id) in [ - ("user", "hello", "direct-codex:old:user"), - ( - "assistant", - "partial\nunexpected interrupt happened here", - "partial-id", - ), - ("tool", "file-read result", "tool-id"), - ("user", "stored raw request", "direct-codex:new-turn:user"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content: content.to_string(), - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &test_llm(), - ) - .expect("build prompt"); - assert_eq!( - prompt, - "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" - ); - } - - #[test] - fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "window-project", "window").expect("init"); - for (role, content, message_id) in [ - ("user", "old ".repeat(1_000), "old-user"), - ("assistant", "middle ".repeat(100), "middle-assistant"), - ("tool", "newest ".repeat(100), "newest-tool"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content, - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let mut llm = test_llm(); - llm.auto_compact_token_limit = 800; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let before = - std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &llm, - ) - .expect("build prompt"); - let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - - assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); - assert!(prompt.contains("tool: newest")); - assert!(!prompt.contains("user: old")); - assert!(prompt.ends_with("user: new request")); - assert_eq!(before, after); - } - - #[test] - fn direct_history_prompt_rejects_current_request_that_exceeds_context() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); - let mut llm = test_llm(); - llm.context_window_tokens = 5_000; - llm.auto_compact_token_limit = 1_000; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(100); - let error = build_direct_codex_history_prompt( - root.path(), - "new-turn", - &"request ".repeat(10_000), - &request, - &llm, - ) - .expect_err("oversized request should fail"); - assert!(error.contains("Direct replay 请求")); - } - #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ @@ -4459,6 +4360,7 @@ mod tests { "fixture-model", workspace, CodexAppServerWorkspaceMode::DirectHome, + None, ); assert_eq!(turn["threadId"], "home-thread"); assert_eq!(turn["approvalPolicy"], "never"); @@ -4519,7 +4421,9 @@ mod tests { "fixture-model", &workspace, CodexAppServerWorkspaceMode::DirectProject, + Some("direct-turn-0001"), ); + assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( turn.pointer("/sandboxPolicy/writableRoots/0"), Some(&serde_json::json!(workspace)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs new file mode 100644 index 000000000..42777dcfa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -0,0 +1,467 @@ +use crate::config::prepare_game_creator_private_path_for_read; +use crate::project::{ + append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, +}; +use crate::{LocalConversationMessageRecord, LocalConversationResult}; +use serde_json::Value; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ + "host_skills.instructions", + "permissions.instructions", + "environments.environment_context", +]; + +const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ + ("# AGENTS.md instructions", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), +]; + +fn is_known_contextual_user_text(text: &str) -> bool { + let text = text.trim(); + if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS + .iter() + .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + { + return true; + } + if text.starts_with("") { + return true; + } + text.starts_with("') + .and_then(|(start, _)| start.strip_prefix(""))) +} + +pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { + if matches!( + item.get("role").and_then(Value::as_str), + Some("developer" | "system") + ) { + return true; + } + if item + .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + .and_then(Value::as_array) + .is_some_and(|kinds| { + kinds + .iter() + .filter_map(Value::as_str) + .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind)) + }) + { + return true; + } + item.get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + let Some(text) = part.get("text").and_then(Value::as_str) else { + return false; + }; + is_known_contextual_user_text(text) + }) + }) +} + +fn history_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/project.jsonl") +} + +fn record(item: &Value) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) +} + +const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; + +fn find_direct_project_history_item_by_id_at( + path: &Path, + item_id: &str, +) -> Result, String> { + let mut file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + let mut pending = Vec::new(); + let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; + + loop { + if position == 0 { + break; + } + let read_len = usize::try_from(position) + .unwrap_or(usize::MAX) + .min(chunk.len()); + position -= read_len as u64; + file.seek(SeekFrom::Start(position)) + .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; + file.read_exact(&mut chunk[..read_len]) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + + let mut combined = Vec::with_capacity(read_len + pending.len()); + combined.extend_from_slice(&chunk[..read_len]); + combined.extend_from_slice(&pending); + let mut line_end = combined.len(); + while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { + let line = &combined[newline + 1..line_end]; + if !line.is_empty() { + if let Some(item) = direct_project_history_item_from_line(path, line)? { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + line_end = newline; + } + pending = combined[..line_end].to_vec(); + } + + if !pending.is_empty() { + // The append path repairs an unterminated final JSONL record before + // writing. A duplicate scan must not reject that repairable tail. + if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + Ok(None) +} + +fn direct_project_history_item_from_line( + path: &Path, + line: &[u8], +) -> Result, String> { + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(None); + } + let parsed: Value = serde_json::from_slice(line) + .map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + return Ok(None); + } + Ok(Some(item)) +} + +pub(crate) fn append_direct_project_history_item_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, false) +} + +/// Appends a user message authored by AGC itself. Codex response items use +/// the default path above, which deliberately ignores echoed user messages; +/// this explicit entry point keeps the two sources distinct. +pub(crate) fn append_direct_project_user_message_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, true) +} + +fn append_direct_project_history_item_at_with_user_policy( + root: &Path, + item: &Value, + allow_user_item: bool, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + if is_direct_project_internal_context_item(item) { + return Ok(()); + } + if !allow_user_item && is_direct_project_codex_user_item(item) { + return Ok(()); + } + let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; + let path = history_path(root); + let history_exists = + prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("DirectProject 历史追加写")?; + if history_exists { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { + if &existing == item { + return Ok(()); + } + return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); + } + } + } + let line = record(item)?; + append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") +} + +fn is_direct_project_codex_user_item(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) == Some("userMessage") { + return true; + } + if item.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + !item + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user")) +} + +pub(crate) fn direct_project_local_message_item( + role: &str, + content: &str, + message_id: Option<&str>, +) -> Result { + let role = role.trim(); + let content = content.trim(); + if content.is_empty() || !matches!(role, "user" | "assistant") { + return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string()); + } + let mut item = serde_json::json!({ + "type": "message", + "role": role, + "content": [{ + "type": if role == "user" { "input_text" } else { "output_text" }, + "text": content, + }], + }); + if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) { + item["id"] = Value::String(message_id.to_string()); + } + Ok(item) +} + +pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok(Vec::new()); + } + let file = File::open(&path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut items = Vec::new(); + let mut reader = BufReader::new(file); + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + if bytes == 0 { + break; + } + let had_newline = line.ends_with('\n'); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(_error) if !had_newline => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )); + } + }; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + continue; + } + items.push(item); + } + Ok(items) +} + +pub(crate) fn read_direct_project_chat_history_at( + root: &Path, +) -> Result { + let path = history_path(root); + let items = read_direct_project_history_items_at(root)?; + let messages = items + .into_iter() + .filter_map(|item| { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + let content = item + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default(); + (!content.is_empty()).then(|| LocalConversationMessageRecord { + schema_version: "agc-direct-project-context.v1".to_string(), + role: role.to_string(), + content, + agent_id: None, + message_id: item.get("id").and_then(Value::as_str).map(str::to_string), + updated_at: 0, + }) + }) + .collect(); + Ok(LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id: None, + session_id: None, + messages, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + append_direct_project_history_item_at, append_direct_project_user_message_at, history_path, + is_direct_project_internal_context_item, read_direct_project_history_items_at, + }; + use serde_json::json; + + #[test] + fn filters_host_context_but_keeps_real_user_items() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": ""}] + }))); + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": ""}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["environments.environment_context"] + } + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "ls ui"}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["user.text"] + } + }))); + } + + #[test] + fn filters_codex_contextual_user_item_without_passthrough_metadata() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n/tmp/project\n" + }] + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "请读取 中的说明" + }] + }))); + } + + #[test] + fn append_repairs_truncated_tail_before_idempotency_scan() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复") + .expect("init project"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + std::fs::write( + &path, + br#"{"type":"response_item","payload":{"type":"message"}"#, + ) + .expect("write truncated history tail"); + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": "tail-repair-item", + "content": [{"type": "output_text", "text": "已修复"}] + }); + append_direct_project_history_item_at(root.path(), &item).expect("repair and append"); + let items = + read_direct_project_history_items_at(root.path()).expect("read repaired history"); + assert_eq!(items, vec![item]); + } + + #[test] + fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤") + .expect("init project"); + let user = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "userMessage", + "id": "codex-user-item-1", + "clientId": "turn-0001", + "content": [{"type": "text", "text": "请创建菜单"}] + }), + ) + .expect("ignore Codex echo"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "message", + "role": "user", + "id": "codex-raw-user-item-1", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }), + ) + .expect("ignore raw Codex user echo"); + let items = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items, vec![user]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs new file mode 100644 index 000000000..c2c317692 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -0,0 +1,54 @@ +use super::direct_project_history::append_direct_project_history_item_at; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct DirectProjectHistoryAccumulator { + text_by_item_id: BTreeMap, +} + +impl DirectProjectHistoryAccumulator { + pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { + self.text_by_item_id + .entry(item_id.to_string()) + .or_default() + .push_str(delta); + } + + pub(crate) fn complete_item(&mut self, item: &Value) { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + self.text_by_item_id.remove(item_id); + } + } + + fn take_partial_items(&mut self) -> impl Iterator + '_ { + std::mem::take(&mut self.text_by_item_id) + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(item_id, text)| { + serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }) + }) + } +} + +pub(crate) fn persist_direct_project_partial_items_at( + root: &Path, + accumulator: &mut DirectProjectHistoryAccumulator, +) -> Result<(), String> { + let items = accumulator.take_partial_items().collect::>(); + let mut first_error = None; + for item in items { + if let Err(error) = append_direct_project_history_item_at(root, &item) { + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error.map_or(Ok(()), Err) +} 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 5498335b2..e7974ba25 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 @@ -4407,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "assistant".to_string(), - content: reply.to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:assistant"), - ) - .map(|_| ()) -} - -fn persist_direct_codex_user_prompt_at( - root: &Path, - client_turn_id: &str, - prompt: &str, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "user".to_string(), - content: prompt.trim().to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:user"), - ) - .map(|_| ()) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4480,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), - 500, - )); - } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4504,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"), - 500, - )); - } audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone())); Ok(reply) @@ -4526,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } +#[cfg(test)] +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "user", + prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + )?; + append_direct_project_user_message_at(root, &item) +} + +#[cfg(test)] +fn persist_direct_codex_assistant_reply_at( + root: &Path, + client_turn_id: &str, + reply: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + reply, + Some(&format!("direct-codex:{client_turn_id}:assistant")), + )?; + append_direct_project_history_item_at(root, &item) +} + #[cfg(test)] mod tests { use super::*; @@ -4610,8 +4578,8 @@ mod tests { persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply) .expect("the App's same-id write converges idempotently"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:assistant"); let persisted = conversation .messages @@ -4624,7 +4592,7 @@ mod tests { assert!( persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复") .expect_err("same message identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } @@ -4641,8 +4609,8 @@ mod tests { persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) .expect("retry reuses the same user message identity"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:user"); let persisted = conversation .messages @@ -4655,7 +4623,7 @@ mod tests { assert!( persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") .expect_err("same turn identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } 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 2f564e863..87ada105d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::read_direct_project_chat_history_at; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -4748,6 +4749,15 @@ pub(crate) fn read_local_conversation( read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } +#[tauri::command] +pub(crate) fn read_direct_project_conversation( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -4776,6 +4786,20 @@ pub(crate) fn append_local_conversation_message( } } +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn build_local_project_index( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8e39c1712..e163cad21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2591,7 +2591,9 @@ fn main() { set_active_game_creator_agent_session, archive_game_creator_agent_session, read_local_conversation, + read_direct_project_conversation, append_local_conversation_message, + append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, export_local_project_package, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index bd2f3e18b..4fd654a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) } -pub(super) struct ProjectAppendLock { +pub(crate) struct ProjectAppendLock { process_lock: Arc>, os_lock_path: PathBuf, } -pub(super) struct ProjectAppendGuard<'a> { +pub(crate) struct ProjectAppendGuard<'a> { _process_guard: std::sync::MutexGuard<'a, ()>, _os_lock: File, } @@ -4335,7 +4335,7 @@ impl ProjectAppendLock { .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) } - pub(super) fn lock(&self, error_label: &str) -> Result, String> { + pub(crate) fn lock(&self, error_label: &str) -> Result, String> { let process_guard = self .process_lock .lock() @@ -4348,7 +4348,7 @@ impl ProjectAppendLock { } } -pub(super) fn project_append_lock_for(path: &Path) -> Result { +pub(crate) fn project_append_lock_for(path: &Path) -> Result { let mut locks = project_append_locks() .lock() .map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?; @@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -617,10 +613,6 @@ export function App({ receivedDirectUpdate: boolean; } | null>(null); const lastDirectCodexActivityRef = useRef(null); - const recoveredDirectCodexTurnClaimsRef = useRef(new Set()); - const directCodexClaimReleaseOnConversationWriteFailureRef = useRef( - new Map(), - ); const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null @@ -1801,6 +1793,16 @@ export function App({ projectConversationWriteConfirmedRef.current = null; projectConversationWriteCancelledRef.current = null; } + // DirectProject history is written by Rust from raw app-server items. + // The browser only renders that projection and must not append chat rows. + if (projectSupervisorOnly && directCodexProductRuntime) { + // `messages` is only an optimistic UI projection in Direct mode; it is + // intentionally not proof of durability. Rust owns the raw response + // history, so this effect must not route these rows through the generic + // browser conversation writer. + savedConversationCountRef.current = messages.length; + return; + } const start = savedConversationCountRef.current; const pendingMessages = messages.slice(start); if (pendingMessages.length === 0) { @@ -1871,7 +1873,6 @@ export function App({ return; } conversationWriteInFlightRef.current = true; - let failedDirectTerminalMessageCount: number | null = null; void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { @@ -1886,56 +1887,22 @@ export function App({ savedConversationCountRef.current = start + index + 1; continue; } - try { - await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, + await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), + message: { + role: message.role, + content: message.text, agentId: null, - ...(message.messageId ? { messageId: message.messageId } : {}), - message: { - role: message.role, - content: message.text, - agentId: null, - ...(typeof message.updatedAt === 'number' - ? { updatedAt: message.updatedAt } - : {}), - }, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, - ); - } catch (error) { - const claimKey = message.messageId - ? directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ) - : undefined; - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId!, - ); - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - failedDirectTerminalMessageCount = start + index + 1; - } - throw error; - } - if (message.messageId) { - const claimKey = - directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ); - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId, - ); - if (localProjectPathRef.current === nextProjectPath) { - // Any history read started before this terminal append may hold - // A stale history snapshot may still be missing this terminal - // append. Invalidate it before releasing the in-memory claim. - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - } - } + }, + ); wroteMessage = true; savedConversationCountRef.current = start + index + 1; } @@ -1950,12 +1917,10 @@ export function App({ } })() .catch((error) => { - savedConversationCountRef.current = failedDirectTerminalMessageCount - ? Math.max( - savedConversationCountRef.current, - failedDirectTerminalMessageCount, - ) - : Math.min(savedConversationCountRef.current, start); + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + start, + ); setWorkspaceStatus( `项目对话保存失败:${ error instanceof Error ? error.message : String(error) @@ -1985,6 +1950,7 @@ export function App({ conversationWriteVersion, pendingUiConfirmation, projectSupervisorOnly, + directCodexProductRuntime, ]); function appendLocalPermissionLog( @@ -2741,11 +2707,12 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; const projectConversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: null, - }, + directCodexProductRuntime + ? 'read_direct_project_conversation' + : 'read_local_conversation', + directCodexProductRuntime + ? { projectPath: nextProjectPath } + : { projectPath: nextProjectPath, agentId: null }, ); let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; @@ -5450,6 +5417,7 @@ export function App({ clientTurnId: directConversationTurnId, creationType, attachments, + directPolicyChecked = false, }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. @@ -5459,6 +5427,44 @@ export function App({ if (directProjectPath && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); + if ( + !directPolicyChecked && + projectConversationWriteConfirmedRef.current !== directProjectPath + ) { + try { + const policyPaused = await queueProjectPolicyConfirmationIfNeeded( + directInvoke, + 'conversation.write', + directProjectPath, + '写入 DirectProject 对话历史', + 'DirectProject 对话写入需要确认。', + () => { + projectConversationWriteConfirmedRef.current = + directProjectPath; + void executeChatAgentReply({ + prompt, + clientTurnId, + creationType, + attachments, + directPolicyChecked: true, + }); + }, + ); + if (policyPaused) { + return; + } + projectConversationWriteConfirmedRef.current = directProjectPath; + } catch (error) { + if (localProjectPathRef.current === directProjectPath) { + setProjectSupervisorRuntimeError( + `DirectProject 对话权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return; + } + } const directUserMessageId = directCodexConversationMessageId( clientTurnId, 'user', @@ -5470,20 +5476,45 @@ export function App({ const appendDirectUserMessageIfMissing = ( current: ChatMessage[], ): ChatMessage[] => { - return current.some( - (message) => message.messageId === directUserMessageId, - ) - ? current - : [ - ...current, - { - role: 'user' as const, - text: prompt, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - }, - ]; + if ( + current.some((message) => message.messageId === directUserMessageId) + ) { + return current; + } + let optimisticIndex = -1; + for (let index = current.length - 1; index >= 0; index -= 1) { + const message = current[index]; + if ( + message?.role === 'user' && + message.text === prompt && + !message.messageId + ) { + optimisticIndex = index; + break; + } + } + if (optimisticIndex >= 0) { + return current.map((message, index) => + index === optimisticIndex + ? { + ...message, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + } + : message, + ); + } + return [ + ...current, + { + role: 'user' as const, + text: prompt, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + }, + ]; }; const appendDirectAssistantMessage = ( current: ChatMessage[], @@ -5507,41 +5538,6 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - const persistDirectAssistantMessage = (text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directAssistantMessageId, - message: { - role: 'assistant', - content: text, - agentId: null, - }, - }, - ); - const persistDirectPartialMessage = (messageId: string, text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId, - message: { - role: 'assistant', - // An interrupted partial is intentionally a normal assistant - // record so replay sees exactly what Codex emitted before the - // disconnect; the marker is product data, not UI metadata. - content: `${text.trim()}\nunexpected interrupt happened here`, - agentId: null, - }, - }, - ); - const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; - recoveredDirectCodexTurnClaimsRef.current.add( - recoveredDirectCodexTurnClaimKey, - ); activeDirectCodexTurnRef.current = { projectPath: directProjectPath, turnId: clientTurnId, @@ -5558,36 +5554,6 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { - const interruptedPartial = directCodexInterruptedPartialRef.current; - if ( - interruptedPartial?.projectPath === directProjectPath && - interruptedPartial.text.trim() - ) { - await persistDirectPartialMessage( - interruptedPartial.messageId, - interruptedPartial.text, - ); - directCodexInterruptedPartialRef.current = null; - } - // Rust owns the normalized user record for attachment turns so the - // durable message includes the same bounded project mapping that is - // sent to Codex. Plain turns keep the optimistic browser write; the - // Rust writer then converges on it through messageId idempotency. - if (!attachments?.length) { - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, - agentId: null, - }, - }, - ); - } const directTurnInput: { projectPath: string; prompt: string; @@ -5609,28 +5575,11 @@ export function App({ 'chat_with_game_creator_direct_codex', directTurnInput, ); - try { - await persistDirectAssistantMessage(reply); - } catch (error) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - `陶泥儿回复保存失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the in-memory turn claim can be - // released without reopening the Provider side effect. Invalidate - // any history snapshot captured before Rust committed the terminal - // reply first. + // Rust already persisted the complete raw response items. Invalidate + // any history snapshot captured before the turn completed. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setMessages((current) => @@ -5643,9 +5592,6 @@ export function App({ } } catch (error) { if (isDirectCodexTurnAlreadyRunningError(error)) { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( @@ -5665,46 +5611,7 @@ export function App({ '陶泥儿智能创作', true, ); - const partial = directCodexTransientReplyRef.current.trim(); - if (partial) { - const partialMessageId = - globalThis.crypto?.randomUUID?.() || - `direct-partial-${Date.now().toString(36)}`; - directCodexInterruptedPartialRef.current = { - projectPath: directProjectPath, - text: partial, - messageId: partialMessageId, - }; - try { - await persistDirectPartialMessage(partialMessageId, partial); - } catch { - // The next user send retries this idempotent append before - // constructing the replay prompt. - } - } - try { - await persistDirectAssistantMessage(visibleMessage); - if (localProjectPathRef.current === directProjectPath) { - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } catch { - if (localProjectPathRef.current === directProjectPath) { - // Do not release the claim while the React conversation writer - // can still persist this terminal record. That writer releases - // the claim only after its exact append resolves or rejects. - directCodexClaimReleaseOnConversationWriteFailureRef.current.set( - directAssistantMessageId, - recoveredDirectCodexTurnClaimKey, - ); - } else { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } - } + projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setDirectCodexStatus('failed'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e83a74396..ad1c1bb67 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2122,6 +2122,7 @@ export function registerHomeProjectCreationTests() { 'existing-project', '已有项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { @@ -2131,14 +2132,45 @@ export function registerHomeProjectCreationTests() { if (command === 'append_local_permission_log') { return {}; } - if (command === 'append_local_conversation_message') { + if (command === 'hydrate_game_creator_plan_gdd_state') { + return null; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, - messages: [], + sessionId: null, + messages: [...persistedMessages], }; } + if (command === 'append_local_conversation_message') { + throw new Error( + 'DirectProject must not use browser conversation writer', + ); + } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + }, + { + role: 'assistant', + content: 'DIRECT_EXISTING_PROJECT_OK', + messageId: `direct-codex:${clientTurnId}:assistant`, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2165,32 +2197,18 @@ export function registerHomeProjectCreationTests() { }, ); }); - const persistedTurnCall = invoke.mock.calls.findIndex( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId !== undefined, - ); const directTurnCall = invoke.mock.calls.findIndex( ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(persistedTurnCall).toBeGreaterThanOrEqual(0); - expect(persistedTurnCall).toBeLessThan(directTurnCall); - const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as - | Record - | undefined; const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as | Record | undefined; - expect(persistedTurnArgs).toEqual({ - projectPath, - agentId: null, - messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`, - message: { - role: 'user', - content: '继续修改已有项目', - agentId: null, - }, - }); + expect(directTurnCall).toBeGreaterThanOrEqual(0); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String)); expect(invoke).not.toHaveBeenCalledWith( 'create_automatic_local_game_project', ); @@ -2214,7 +2232,10 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return manifest; } - if (command === 'read_local_conversation') { + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, @@ -2257,6 +2278,23 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + messageId: `direct-codex:${clientTurnId}:assistant`, + updatedAt: 2, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); diff --git a/docs/README.md b/docs/README.md index 7453573fe..95647a6f4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8dcc07aa3..00092043a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7961,6 +7961,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。 - 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。 +## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显 + +- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 +- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 + ## 2026-08-31 AGC 错误报告与诊断上传 - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 9d754cb5d..ffd3726a7 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -22,14 +22,15 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` -2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` -3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` -4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` -6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` -7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -9. UI 编辑器、宿主壳和当前测试专题文档 +2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` +3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` +4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` +5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` +6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +10. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 79a527af8..2f63f00e6 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -238,7 +238,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 -- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md new file mode 100644 index 000000000..de0c1b0a2 --- /dev/null +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -0,0 +1,58 @@ +# DirectProject Codex 原始历史与异常恢复 + +更新时间:`2026-09-07` + +## 目标 + +DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。 + +本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。 + +## 文件格式 + +每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段: + +```json +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} +``` + +`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 + +DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。 + +## 正常回合 + +1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 +2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 +4. 正常 `turn/completed: completed` 不生成额外记录。 + +## 异常回合收尾 + +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。 + +`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: + +```json +{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}} +``` + +合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。 + +`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。 + +Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。 + +## 恢复 + +创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。 + +`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。 + +聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 + +DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 + +## 写入与损坏边界 + +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。