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 78d3e3ee0..bc2bedb87 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; mod codex_provider_proxy; +mod direct_codex_attachments; +mod direct_codex_audit; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -34,6 +36,8 @@ pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +pub(crate) use direct_codex_attachments::*; +pub(crate) use direct_codex_audit::*; 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 5a1d30e9f..6d3aa6237 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 @@ -1905,8 +1905,15 @@ impl CodexAppServerConnection { request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, ) -> Result { - self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) - .await + self.run_turn_with_direct_observer( + snapshot, + llm, + request, + on_agent_message_delta, + None, + None, + ) + .await } async fn run_turn_with_direct_observer( @@ -1916,6 +1923,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, 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?; @@ -2085,6 +2093,11 @@ impl CodexAppServerConnection { completed, ¶ms, ); + if completed { + if let Some(audit) = audit.as_mut() { + audit.observe_item(¶ms); + } + } } if item_type == "agentMessage" { if let Some(text) = item @@ -2796,8 +2809,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at( system_prompt: String, user_prompt: String, ) -> Result { - direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None) - .await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + user_prompt, + None, + None, + ) + .await } pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( @@ -2811,6 +2830,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( system_prompt, user_prompt, Some(observer), + None, ) .await } @@ -2861,11 +2881,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) format!("{:x}", digest.finalize()) } -async fn direct_game_creator_codex_chat_at_with_optional_observer( +pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -2917,7 +2938,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer( .await .map_err(|error| error.to_string())?; connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer) + .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -4245,6 +4266,7 @@ while IFS= read -r line; do :; done tool_request(), Some(&mut on_delta), Some(&mut observer), + None, ) .await .expect("run fake app-server turn"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs new file mode 100644 index 000000000..8cf49e017 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -0,0 +1,431 @@ +//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。 +//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 + +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; +const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; +const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; + +const HOME_ATTACHMENT_HEADER: &str = + "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]"; +const PROJECT_ATTACHMENT_HEADER: &str = + "[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]"; + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexTurnAttachment { + pub(crate) name: String, + pub(crate) media_type: String, + #[serde(default)] + pub(crate) size: u64, + #[serde(default)] + pub(crate) local_path: Option, + #[serde(default)] + pub(crate) status: Option, +} + +pub(crate) fn sanitize_attachment_name(value: &str) -> String { + let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); + let sanitized = basename + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS) + .collect::(); + if sanitized.is_empty() { + "未命名附件".to_string() + } else { + sanitized + } +} + +pub(crate) fn sanitize_attachment_media_type(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + "application/octet-stream".to_string() + } else { + value + .chars() + .take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS) + .collect() + } +} + +pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { + match value.map(str::trim) { + Some("imported") => Some("imported"), + Some("failed") => Some("failed"), + _ => None, + } +} + +pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS + || trimmed.chars().any(char::is_control) + { + return None; + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') { + return None; + } + + let mut chars = normalized.chars(); + if let (Some(letter), Some(':')) = (chars.next(), chars.next()) { + if letter.is_ascii_alphabetic() { + return None; + } + } + + let mut segments = Vec::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + segments.push(segment); + } + let first = segments.first()?; + if *first == ".agent" || *first == ".git" { + return None; + } + let path = segments.join("/"); + if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS { + return None; + } + Some(path) +} + +pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool { + attachments.iter().any(|attachment| { + attachment + .local_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || sanitize_attachment_status(attachment.status.as_deref()).is_some() + }) +} + +fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + + let mut parts = vec![format!("原文件名:{name}")]; + if let Some(path) = sanitized_path { + parts.push(format!("项目路径:{path}")); + } + parts.push(format!("类型:{media_type}")); + parts.push(format!("大小:{} 字节", attachment.size)); + if let Some(status) = status { + parts.push(format!("状态:{status}")); + } + format!("- {}", parts.join(";")) +} + +pub(crate) fn render_direct_codex_user_prompt( + prompt: &str, + attachments: &[DirectCodexTurnAttachment], +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() && attachments.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + if attachments.is_empty() { + return Ok(prompt.to_string()); + } + + let mut sections = Vec::new(); + if !prompt.is_empty() { + sections.push(prompt.to_string()); + sections.push(String::new()); + } + if attachments_use_project_mapping(attachments) { + sections.push(PROJECT_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(render_project_attachment_line(attachment)); + } + } else { + sections.push(HOME_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(format!( + "- {};类型:{};大小:{} 字节", + sanitize_attachment_name(&attachment.name), + sanitize_attachment_media_type(&attachment.media_type), + attachment.size, + )); + } + } + if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS { + sections.push(format!( + "- 另有 {} 个附件未展开", + attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS + )); + } + Ok(sections.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: None, + status: None, + } + } + + fn project_attachment( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: local_path.map(str::to_string), + status: status.map(str::to_string), + } + } + + #[test] + fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() { + assert_eq!( + render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"), + "你好" + ); + assert_eq!( + render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"), + "聊天内容不能为空" + ); + } + + #[test] + fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { + let attachments = vec![home_attachment( + r"C:\Users\secret\角色参考.png", + "image/png\nBearer secret", + 3, + )]; + + let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments) + .expect("home prompt"); + + assert_eq!( + prompt, + "先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节" + ); + assert!(!prompt.contains("C:\\Users")); + assert!(!prompt.contains("\nBearer secret")); + } + + #[test] + fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { + assert_eq!( + render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"), + "你好" + ); + let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2) + .map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64)) + .collect::>(); + let prompt = + render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments"); + assert!(prompt.contains("asset-7.png")); + assert!(!prompt.contains("asset-8.png")); + assert!(prompt.contains("另有 2 个附件未展开")); + assert!(render_direct_codex_user_prompt("", &attachments).is_ok()); + assert!(render_direct_codex_user_prompt("", &[]).is_err()); + } + + #[test] + fn home_json_without_path_or_status_still_deserializes() { + let attachment: DirectCodexTurnAttachment = + serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#) + .expect("home json"); + assert!(attachment.local_path.is_none()); + assert!(attachment.status.is_none()); + assert_eq!(attachment.size, 3); + } + + #[test] + fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() { + let attachments = vec![project_attachment( + "fast_gdd.md", + "text/markdown", + 7944, + Some("assets/uploads/upload-1788083777445-fast_gdd.md"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments) + .expect("project prompt"); + + assert_eq!( + prompt, + "请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported" + ); + assert!(!prompt.contains("GDD")); + assert!(!prompt.contains("规格")); + assert!(!prompt.contains("权威")); + assert!(!prompt.contains("必须读取")); + } + + #[test] + fn project_png_and_markdown_share_the_same_line_shape() { + let attachments = vec![ + project_attachment( + "角色参考.png", + "image/png", + 12, + Some("assets/uploads/upload-1-角色参考.png"), + Some("imported"), + ), + project_attachment( + "notes.md", + "text/markdown", + 80, + Some("assets/uploads/upload-2-notes.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types"); + let lines: Vec<_> = prompt + .lines() + .filter(|line| line.starts_with("- 原文件名:")) + .collect(); + assert_eq!(lines.len(), 2); + for line in &lines { + assert!(line.contains(";项目路径:assets/uploads/")); + assert!(line.contains(";类型:")); + assert!(line.contains(";大小:")); + assert!(line.contains(";状态:imported")); + } + assert!(lines[0].contains("角色参考.png")); + assert!(lines[0].contains("image/png")); + assert!(lines[1].contains("notes.md")); + assert!(lines[1].contains("text/markdown")); + } + + #[test] + fn failed_attachment_without_path_has_status_and_no_error_body() { + let attachments = vec![project_attachment( + "lost.bin", + "application/octet-stream", + 2, + None, + Some("failed"), + )]; + let prompt = + render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt"); + assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("原文件名:lost.bin")); + assert!(prompt.contains("状态:failed")); + assert!(!prompt.contains("项目路径:")); + assert!(!prompt.contains("error")); + assert!(!prompt.contains("失败原因")); + } + + #[test] + fn illegal_local_paths_are_omitted_and_marked_failed() { + let attachments = vec![ + project_attachment( + "up.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + project_attachment( + "agent.md", + "text/markdown", + 1, + Some(".agent/conversations/x.md"), + Some("imported"), + ), + project_attachment( + "abs.md", + "text/markdown", + 1, + Some(r"C:\tmp\abs.md"), + Some("imported"), + ), + project_attachment( + "unix.md", + "text/markdown", + 1, + Some("/tmp/unix.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths"); + assert!(!prompt.contains("../secret.md")); + assert!(!prompt.contains(".agent/conversations/x.md")); + assert!(!prompt.contains("C:\\tmp\\abs.md")); + assert!(!prompt.contains("/tmp/unix.md")); + assert!(!prompt.contains("项目路径:")); + assert_eq!(prompt.matches("状态:failed").count(), 4); + assert!(!prompt.contains("状态:imported")); + } + + #[test] + fn empty_prompt_with_project_attachments_still_renders() { + let attachments = vec![project_attachment( + "ref.png", + "image/png", + 4, + Some("assets/uploads/upload-1-ref.png"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text"); + assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png")); + } + + #[test] + fn unknown_error_field_is_not_forwarded_to_the_model() { + let attachment: DirectCodexTurnAttachment = serde_json::from_str( + r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#, + ) + .expect("extra error field"); + let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); + assert!(!prompt.contains("secret boom")); + assert!(!prompt.contains("error")); + } + + #[test] + fn unknown_status_keeps_home_attachment_metadata_shape() { + let attachment = + project_attachment("pending.md", "text/markdown", 1, None, Some("pending")); + let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); + assert!(prompt.contains(HOME_ATTACHMENT_HEADER)); + assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER)); + assert!(!prompt.contains("状态:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs new file mode 100644 index 000000000..bc34a6b4c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -0,0 +1,1458 @@ +//! Direct Codex GUI 回合行为账本:把 item/completed 抽成项目内有界时间线。 +//! 不灌附件正文、不落 stdout / patch / MCP result,不进入前端观察者。 + +use super::*; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DIRECT_CODEX_AUDIT_HASH_MAX_BYTES: u64 = 2 * 1024 * 1024; +const DIRECT_CODEX_AUDIT_MAX_ITEMS: usize = 256; +const DIRECT_CODEX_AUDIT_COMMAND_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_BRIEF_CHARS: usize = 4000; +const DIRECT_CODEX_AUDIT_PREVIEW_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_QUERY_CHARS: usize = 400; +const DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS: usize = 120; +const DIRECT_CODEX_AUDIT_ID_LIST_MAX: usize = 8; +const DIRECT_CODEX_AUDIT_TURN_LOG_DIR: &str = ".agent/runtime/direct-codex/turns"; + +const SKIPPED_ITEM_TYPES: &[&str] = &[ + "agentMessage", + "userMessage", + "plan", + "reasoning", + "contextCompaction", + "hookPrompt", +]; + +const DESIGN_MCP_TOOLS: &[&str] = &[ + "taonier_prepare_game_art", + "agc_generate_image", + "agc_edit_image", + "agc_create_or_derive_resource", +]; + +struct OfferedAttachment { + local_path: String, + content_sha256: Option, + read: bool, + content_sha256_match: Option, +} + +pub(crate) struct DirectCodexTurnAudit { + root: PathBuf, + client_turn_id: String, + log_path: PathBuf, + turn_log_relative: String, + sidecar_present: bool, + offered: Vec, + item_count: usize, + items_truncated: bool, + truncated_written: bool, + first_design: Option, + audit_write_failed: bool, + finished: bool, +} + +impl DirectCodexTurnAudit { + pub(crate) fn start( + root: &Path, + client_turn_id: &str, + original_prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> Self { + let turn_log_relative = format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/{client_turn_id}.jsonl"); + let log_path = root.join(&turn_log_relative); + let sidecar_present = attachments_use_project_mapping(attachments); + let (attachment_values, offered) = project_audit_attachments(root, attachments); + let mut audit = Self { + root: root.to_path_buf(), + client_turn_id: client_turn_id.to_string(), + log_path, + turn_log_relative, + sidecar_present, + offered, + item_count: 0, + items_truncated: false, + truncated_written: false, + first_design: None, + audit_write_failed: false, + finished: false, + }; + let omitted = attachments + .len() + .saturating_sub(MAX_DIRECT_CODEX_ATTACHMENTS); + let mut record = json!({ + "recordType": "direct.codex.turn_start", + "clientTurnId": client_turn_id, + "sidecarPresent": sidecar_present, + "promptSha256": sha256_hex(original_prompt.as_bytes()), + "promptChars": original_prompt.chars().count(), + "attachments": attachment_values, + }); + if omitted > 0 { + record["attachmentsOmitted"] = json!(omitted); + } + audit.append_record(record); + audit + } + + pub(crate) fn observe_item(&mut self, params: &Value) { + if self.finished { + return; + } + let Some(item) = params.get("item") else { + return; + }; + let item_type = item + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if SKIPPED_ITEM_TYPES.contains(&item_type) { + return; + } + if self.item_count >= DIRECT_CODEX_AUDIT_MAX_ITEMS { + self.items_truncated = true; + if !self.truncated_written { + self.truncated_written = true; + self.append_record(json!({ + "recordType": "direct.codex.items_truncated", + "clientTurnId": self.client_turn_id, + "droppedAfter": DIRECT_CODEX_AUDIT_MAX_ITEMS, + })); + } + return; + } + + self.item_count = self.item_count.saturating_add(1); + let seq = self.item_count; + let mut record = json!({ + "recordType": "direct.codex.item", + "clientTurnId": self.client_turn_id, + "seq": seq, + "itemType": item_type, + }); + if let Some(item_id) = item + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + record["itemId"] = json!(item_id); + } + if let Some(status) = item.get("status").and_then(Value::as_str) { + record["status"] = json!(status); + } else { + record["status"] = json!("completed"); + } + + match item_type { + "commandExecution" => self.fill_command_execution(&mut record, item), + "mcpToolCall" => self.fill_mcp_tool_call(&mut record, item, seq), + "fileChange" => self.fill_file_change(&mut record, item, seq), + "imageView" => self.fill_image_view(&mut record, item), + "functionCallOutput" => fill_function_call_output(&mut record, item), + "webSearch" => fill_web_search(&mut record, item), + _ => {} + } + + self.append_record(record); + } + + pub(crate) fn finish(&mut self, completed: bool) { + if self.finished { + return; + } + self.finished = true; + let offered_read = self.offered_read_values(); + let record = json!({ + "recordType": "direct.codex.turn_end", + "clientTurnId": self.client_turn_id, + "completed": completed, + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + }); + self.append_record(record); + let summary = json!({ + "recordType": "direct.codex.turn", + "clientTurnId": self.client_turn_id, + "turnLog": self.turn_log_relative, + "sidecarPresent": self.sidecar_present, + "offeredCount": self.offered.len(), + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "completed": completed, + "auditWriteFailed": self.audit_write_failed, + }); + if append_agent_db_record(&self.root, summary).is_err() { + self.audit_write_failed = true; + } + } + + fn fill_command_execution(&mut self, record: &mut Value, item: &Value) { + let mut path_rejected = false; + let mut actions = Vec::new(); + if let Some(raw_actions) = item.get("commandActions").and_then(Value::as_array) { + for action in raw_actions { + let action_type = action + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + match action_type { + "read" => { + let (entry, rejected) = self.read_action_entry(action); + path_rejected |= rejected; + actions.push(entry); + } + "listFiles" => { + let mut entry = json!({ "type": "listFiles" }); + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + "search" => { + let mut entry = json!({ "type": "search" }); + if let Some(query) = action.get("query").and_then(Value::as_str) { + entry["query"] = + json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + _ => actions.push(json!({ "type": "unknown" })), + } + } + } + record["actions"] = json!(actions); + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + record["exitCode"] = json!(exit_code); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + let command = item.get("command").and_then(Value::as_str).unwrap_or(""); + if path_rejected || command_contains_host_absolute_path(command) { + record["commandRedacted"] = json!(true); + } else if !command.is_empty() { + record["command"] = json!(truncate_chars(command, DIRECT_CODEX_AUDIT_COMMAND_CHARS)); + } + } + + fn read_action_entry(&mut self, action: &Value) -> (Value, bool) { + let Some(raw_path) = action.get("path").and_then(Value::as_str) else { + return (json!({ "type": "read", "pathRejected": true }), true); + }; + match relativize_project_path(&self.root, raw_path) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + let mut entry = json!({ "type": "read", "path": path }); + insert_hash_fields(&mut entry, content_sha256, hash_skipped); + (entry, false) + } + None => (json!({ "type": "read", "pathRejected": true }), true), + } + } + + fn fill_mcp_tool_call(&mut self, record: &mut Value, item: &Value, seq: usize) { + let tool = item.get("tool").and_then(Value::as_str).unwrap_or(""); + record["tool"] = json!(tool); + if let Some(server) = item + .get("server") + .and_then(Value::as_str) + .filter(|server| !server.is_empty() && *server != "agc_tools") + { + record["server"] = json!(server); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + if item.get("error").is_some() { + record["errorKind"] = json!(item + .pointer("/error/code") + .and_then(Value::as_str) + .or_else(|| item.pointer("/error/type").and_then(Value::as_str)) + .unwrap_or("error")); + } + let arguments = item.get("arguments").cloned().unwrap_or(Value::Null); + let extracted = extract_mcp_arguments(&self.root, tool, &arguments); + if let Some(path) = extracted + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + { + self.mark_offered_read(&path, None); + } + if let Some(local_paths) = extracted.get("localPaths").and_then(Value::as_array) { + for path in local_paths { + if let Some(path) = path.as_str() { + self.mark_offered_read(path, None); + } + } + } + if extracted + .as_object() + .is_some_and(|object| !object.is_empty()) + { + record["arguments"] = extracted.clone(); + } + if self.first_design.is_none() { + if DESIGN_MCP_TOOLS.contains(&tool) { + let mut design = json!({ + "kind": format!("mcp:{tool}"), + "seq": seq, + "tool": tool, + }); + let preview = extracted + .get("brief") + .or_else(|| extracted.get("prompt")) + .and_then(Value::as_str) + .map(|text| truncate_chars(text, DIRECT_CODEX_AUDIT_PREVIEW_CHARS)); + if let Some(preview) = preview { + design["briefPreview"] = json!(preview); + } + self.first_design = Some(design); + } else if tool == "agc_write_file" { + if let Some(path) = extracted.get("path").and_then(Value::as_str) { + if is_design_write_path(path) { + self.first_design = Some(json!({ + "kind": format!("write:{path}"), + "seq": seq, + "path": path, + })); + } + } + } + } + } + + fn fill_file_change(&mut self, record: &mut Value, item: &Value, seq: usize) { + let mut changes = Vec::new(); + if let Some(raw_changes) = item.get("changes").and_then(Value::as_array) { + for change in raw_changes { + let kind = file_change_kind(change); + let mut entry = json!({ "kind": kind }); + match change.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + if self.first_design.is_none() && is_design_write_path(&path) { + self.first_design = Some(json!({ + "kind": format!("fileChange:{path}"), + "seq": seq, + "path": path, + })); + } + entry["path"] = json!(path); + } + None => entry["pathRejected"] = json!(true), + }, + None => entry["pathRejected"] = json!(true), + } + changes.push(entry); + } + } + record["changes"] = json!(changes); + } + + fn fill_image_view(&mut self, record: &mut Value, item: &Value) { + match item.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + record["path"] = json!(path); + insert_hash_fields(record, content_sha256, hash_skipped); + } + None => record["pathRejected"] = json!(true), + }, + None => record["pathRejected"] = json!(true), + } + } + + fn mark_offered_read(&mut self, path: &str, content_sha256: Option<&str>) { + for offered in &mut self.offered { + if offered.local_path != path { + continue; + } + offered.read = true; + match (offered.content_sha256.as_deref(), content_sha256) { + (Some(expected), Some(actual)) => { + let matches = expected == actual; + offered.content_sha256_match = + Some(offered.content_sha256_match.unwrap_or(true) && matches); + } + _ => {} + } + } + } + + fn offered_read_values(&self) -> Vec { + self.offered + .iter() + .map(|offered| { + let mut value = json!({ + "localPath": offered.local_path, + "read": offered.read, + }); + if let Some(matches) = offered.content_sha256_match { + value["contentSha256Match"] = json!(matches); + } + value + }) + .collect() + } + + fn append_record(&mut self, mut record: Value) { + #[cfg(test)] + if test_fail_audit_write(&self.root) { + self.audit_write_failed = true; + return; + } + if let Some(object) = record.as_object_mut() { + object.insert( + "recordedAtMs".to_string(), + json!(u64::try_from(unix_millis()).unwrap_or(u64::MAX)), + ); + } + let Ok(line) = serde_json::to_string(&record) else { + self.audit_write_failed = true; + return; + }; + if append_jsonl_line(&self.log_path, &line, "Direct 回合审计记录").is_err() { + self.audit_write_failed = true; + } + } +} + +impl Drop for DirectCodexTurnAudit { + fn drop(&mut self) { + if !self.finished { + self.finish(false); + } + } +} + +enum ActionPath { + Missing, + Rejected, + Ok(String), +} + +fn optional_action_path(root: &Path, action: &Value) -> ActionPath { + let Some(raw) = action.get("path").and_then(Value::as_str) else { + return ActionPath::Missing; + }; + if raw.trim().is_empty() { + return ActionPath::Missing; + } + match relativize_project_path(root, raw) { + Some(path) => ActionPath::Ok(path), + None => ActionPath::Rejected, + } +} + +fn project_audit_attachments( + root: &Path, + attachments: &[DirectCodexTurnAttachment], +) -> (Vec, Vec) { + let mut values = Vec::new(); + let mut offered = Vec::new(); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": attachment.size, + }); + if let Some(path) = sanitized_path { + value["localPath"] = json!(path.clone()); + let (content_sha256, hash_skipped) = hash_project_file(root, &path); + insert_hash_fields(&mut value, content_sha256.clone(), hash_skipped); + offered.push(OfferedAttachment { + local_path: path, + content_sha256, + read: false, + content_sha256_match: None, + }); + } + if let Some(status) = status { + value["status"] = json!(status); + } + values.push(value); + } + (values, offered) +} + +fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { + let Some(object) = arguments.as_object() else { + return json!({}); + }; + let mut out = Map::new(); + match tool { + "agc_list_project_files" => { + copy_sanitized_path(root, object, "path", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_string(object, "kind", &mut out); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_write_file" => { + copy_sanitized_path(root, object, "path", &mut out); + if let Some(content) = object.get("content").and_then(Value::as_str) { + out.insert("contentChars".to_string(), json!(content.chars().count())); + } + } + "taonier_prepare_game_art" => { + copy_string(object, "mode", &mut out); + copy_text_with_hash( + object, + "brief", + "brief", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_generate_image" => { + copy_string(object, "kind", &mut out); + copy_string(object, "aspectRatio", &mut out); + copy_string(object, "imageSize", &mut out); + copy_string(object, "assetName", &mut out); + copy_sanitized_path(root, object, "outputPath", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_edit_image" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_create_or_derive_resource" => { + copy_string(object, "kind", &mut out); + copy_string(object, "mode", &mut out); + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_list_registered_assets" => { + copy_string(object, "kind", &mut out); + copy_string(object, "assetId", &mut out); + if let Some(flag) = object.get("includeSequenceFrames").and_then(Value::as_bool) { + out.insert("includeSequenceFrames".to_string(), json!(flag)); + } + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_list_account_assets" => { + copy_string(object, "folderId", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_import_account_assets" => { + if let Some(ids) = object.get("assetIds").and_then(Value::as_array) { + let kept: Vec = ids + .iter() + .filter_map(Value::as_str) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + let omitted = ids.len().saturating_sub(kept.len()); + out.insert("assetIds".to_string(), json!(kept)); + if omitted > 0 { + out.insert("assetIdsOmitted".to_string(), json!(omitted)); + } + } + if let Some(paths) = object.get("localPaths").and_then(Value::as_array) { + let kept: Vec = paths + .iter() + .filter_map(Value::as_str) + .filter_map(|path| relativize_project_path(root, path)) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + out.insert("localPaths".to_string(), json!(kept)); + } + } + "agc_remove_background" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + } + "agc_browser_playtest" => copy_number(object, "attempt", &mut out), + "agc_web_search" => { + copy_truncated_string(object, "query", DIRECT_CODEX_AUDIT_QUERY_CHARS, &mut out); + copy_number(object, "maxResults", &mut out); + } + "agc_read_skill_resource" => { + copy_string(object, "skillName", &mut out); + copy_string(object, "relativePath", &mut out); + } + _ => {} + } + Value::Object(out) +} + +fn fill_function_call_output(record: &mut Value, item: &Value) { + if let Some(name) = item.get("name").and_then(Value::as_str) { + record["name"] = json!(name); + } + if let Some(namespace) = item.get("namespace").and_then(Value::as_str) { + record["namespace"] = json!(namespace); + } +} + +fn fill_web_search(record: &mut Value, item: &Value) { + if let Some(query) = item.get("query").and_then(Value::as_str) { + record["query"] = json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } +} + +fn copy_string(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_truncated_string( + source: &Map, + key: &str, + max_chars: usize, + out: &mut Map, +) { + if let Some(value) = source.get(key).and_then(Value::as_str) { + out.insert(key.to_string(), json!(truncate_chars(value, max_chars))); + } +} + +fn copy_number(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source.get(key).and_then(Value::as_i64) { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_sanitized_path( + root: &Path, + source: &Map, + key: &str, + out: &mut Map, +) { + let Some(raw) = source.get(key).and_then(Value::as_str) else { + return; + }; + match relativize_project_path(root, raw) { + Some(path) => { + out.insert(key.to_string(), json!(path)); + } + None => { + out.insert(format!("{key}Rejected"), json!(true)); + } + } +} + +fn copy_text_with_hash( + source: &Map, + source_key: &str, + dest_key: &str, + max_chars: usize, + out: &mut Map, +) { + let Some(text) = source.get(source_key).and_then(Value::as_str) else { + return; + }; + out.insert(format!("{dest_key}Chars"), json!(text.chars().count())); + out.insert( + format!("{dest_key}Sha256"), + json!(sha256_hex(text.as_bytes())), + ); + out.insert(dest_key.to_string(), json!(truncate_chars(text, max_chars))); +} + +fn file_change_kind(change: &Value) -> &'static str { + let kind = change.get("kind"); + let label = kind + .and_then(Value::as_str) + .or_else(|| { + kind.and_then(|value| value.get("type")) + .and_then(Value::as_str) + }) + .unwrap_or("update"); + match label { + "add" => "add", + "delete" => "delete", + _ => "update", + } +} + +fn is_design_write_path(path: &str) -> bool { + path == "index.html" + || path.starts_with("game/") + || path.rsplit('/').next() == Some("index.html") +} + +fn insert_hash_fields( + target: &mut Value, + content_sha256: Option, + hash_skipped: Option<&str>, +) { + if let Some(content_sha256) = content_sha256 { + target["contentSha256"] = json!(content_sha256); + } + if let Some(hash_skipped) = hash_skipped { + target["hashSkipped"] = json!(hash_skipped); + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn hash_project_file(root: &Path, relative: &str) -> (Option, Option<&'static str>) { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return (None, Some("missing")); + } + let path = match resolve_local_project_path(root, relative) { + Ok(path) => path, + Err(_) => return (None, Some("missing")), + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => return (None, Some("missing")), + }; + if metadata.len() > DIRECT_CODEX_AUDIT_HASH_MAX_BYTES { + return (None, Some("too-large")); + } + match fs::read(&path) { + Ok(bytes) => (Some(sha256_hex(&bytes)), None), + Err(_) => (None, Some("missing")), + } +} + +fn posix_path_text(path: &Path) -> String { + let text = path.to_string_lossy(); + let text = text + .strip_prefix(r"\\?\") + .or_else(|| text.strip_prefix("//?/")) + .unwrap_or(&text); + text.replace('\\', "/").trim_end_matches('/').to_string() +} + +fn relativize_project_path(root: &Path, raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(relative) = sanitize_attachment_local_path(trimmed) { + return accept_relative_path(&relative); + } + if let Some(relative) = strip_absolute_root_prefix(root, trimmed) { + return sanitize_attachment_local_path(&relative) + .and_then(|path| accept_relative_path(&path)); + } + None +} + +fn strip_absolute_root_prefix(root: &Path, raw: &str) -> Option { + if let (Ok(root_canon), Ok(raw_canon)) = (root.canonicalize(), Path::new(raw).canonicalize()) { + if let Ok(stripped) = raw_canon.strip_prefix(&root_canon) { + let relative = posix_path_text(stripped); + if !relative.is_empty() { + return Some(relative); + } + } + } + let root_text = posix_path_text(root); + let raw_text = posix_path_text(Path::new(raw)); + let rest = if cfg!(windows) { + let root_lower = root_text.to_ascii_lowercase(); + let raw_lower = raw_text.to_ascii_lowercase(); + let suffix = raw_lower.strip_prefix(&root_lower)?; + raw_text + .get(raw_text.len().saturating_sub(suffix.len())..) + .unwrap_or(suffix) + .to_string() + } else { + raw_text.strip_prefix(&root_text)?.to_string() + }; + let rest = rest.trim_start_matches('/').to_string(); + (!rest.is_empty()).then_some(rest) +} + +fn accept_relative_path(relative: &str) -> Option { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return None; + } + Some(relative.to_string()) +} + +fn command_contains_host_absolute_path(command: &str) -> bool { + if command.contains("\\\\") || command.contains("/Users/") || command.contains("/home/") { + return true; + } + let bytes = command.as_bytes(); + let mut index = 0; + while index + 2 < bytes.len() { + if bytes[index].is_ascii_alphabetic() + && bytes[index + 1] == b':' + && matches!(bytes[index + 2], b'\\' | b'/') + { + return true; + } + index += 1; + } + false +} + +#[cfg(test)] +fn test_fail_audit_write(root: &Path) -> bool { + root.join(".agent/runtime/test-fail-direct-codex-audit") + .is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_project(name: &str) -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + init_local_game_project_at(directory.path(), name, "审计测试项目").expect("init project"); + directory + } + + fn attachment_json( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": size, + }); + if let Some(local_path) = local_path { + value["localPath"] = json!(local_path); + } + if let Some(status) = status { + value["status"] = json!(status); + } + serde_json::from_value(value).expect("attachment") + } + + fn read_turn_log(root: &Path, client_turn_id: &str) -> Vec { + let path = root + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join(format!("{client_turn_id}.jsonl")); + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("audit jsonl")) + .collect() + } + + fn read_agent_db(root: &Path) -> Vec { + fs::read_to_string(root.join(".agent/agent.db")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() + } + + fn start_audit( + root: &Path, + prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> DirectCodexTurnAudit { + DirectCodexTurnAudit::start(root, "turn-01", prompt, attachments) + } + + #[test] + fn turn_start_hashes_original_prompt_and_sanitizes_attachment_paths() { + let project = fixture_project("audit-start"); + let root = project.path(); + let upload = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads dir"); + fs::write(root.join(upload), "脉冲余烬").expect("write gdd"); + let attachments = vec![ + attachment_json( + "fast_gdd.md", + "text/markdown", + 12, + Some(upload), + Some("imported"), + ), + attachment_json( + "secret.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "请根据附件做游戏", &attachments); + audit.finish(true); + let records = read_turn_log(root, "turn-01"); + let start = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_start") + .expect("turn_start"); + assert_eq!( + start["promptSha256"], + json!(sha256_hex("请根据附件做游戏".as_bytes())) + ); + assert_eq!(start["sidecarPresent"], json!(true)); + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["localPath"], json!(upload)); + assert_eq!( + listed[0]["contentSha256"], + json!(sha256_hex("脉冲余烬".as_bytes())) + ); + assert!(listed[1].get("localPath").is_none()); + assert_eq!(listed[1]["status"], json!("failed")); + assert!(!serde_json::to_string(start).expect("json").contains("..")); + } + + #[test] + fn turn_start_without_attachments_sets_sidecar_absent() { + let project = fixture_project("audit-empty"); + let mut audit = start_audit(project.path(), "继续改游戏", &[]); + audit.finish(true); + let start = &read_turn_log(project.path(), "turn-01")[0]; + assert_eq!(start["sidecarPresent"], json!(false)); + assert_eq!(start["attachments"], json!([])); + } + + #[test] + fn attachment_hash_skips_missing_and_too_large_files() { + let project = fixture_project("audit-hash"); + let root = project.path(); + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + let large_path = "assets/uploads/upload-1-big.bin"; + let missing_path = "assets/uploads/upload-1-missing.md"; + fs::write( + root.join(large_path), + vec![0_u8; (DIRECT_CODEX_AUDIT_HASH_MAX_BYTES as usize) + 1], + ) + .expect("large file"); + let attachments = vec![ + attachment_json( + "big.bin", + "application/octet-stream", + 3, + Some(large_path), + Some("imported"), + ), + attachment_json( + "missing.md", + "text/markdown", + 1, + Some(missing_path), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "x", &attachments); + audit.finish(true); + let start = &read_turn_log(root, "turn-01")[0]; + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["hashSkipped"], json!("too-large")); + assert!(listed[0].get("contentSha256").is_none()); + assert_eq!(listed[1]["hashSkipped"], json!("missing")); + } + + #[test] + fn command_read_relativizes_absolute_path_and_drops_stdout() { + let project = fixture_project("audit-read"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "裂脉炮").expect("gdd"); + let absolute = root.join(relative); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 9, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "id": "item-read", + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "status": "completed", + "exitCode": 0, + "aggregatedOutput": "裂脉炮 SECRET", + "commandActions": [{ + "type": "read", + "name": "fast_gdd.md", + "path": absolute.to_string_lossy(), + "command": format!("type {}", absolute.display()) + }] + } + })); + audit.finish(true); + let item = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let dumped = serde_json::to_string(&item).expect("item json"); + assert!(!dumped.contains("aggregatedOutput")); + assert!(!dumped.contains("裂脉炮 SECRET")); + assert_eq!(item["actions"][0]["path"], json!(relative)); + assert_eq!( + item["actions"][0]["contentSha256"], + json!(sha256_hex("裂脉炮".as_bytes())) + ); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + assert_eq!(end["offeredRead"][0]["contentSha256Match"], json!(true)); + } + + #[test] + fn rejected_read_path_does_not_persist_host_absolute_path() { + let project = fixture_project("audit-reject"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": r"type C:\Users\secret\fast_gdd.md", + "aggregatedOutput": "nope", + "commandActions": [{ + "type": "read", + "path": r"C:\Users\secret\fast_gdd.md" + }] + } + })); + audit.finish(true); + let dumped = fs::read_to_string( + project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl"), + ) + .expect("log"); + assert!(!dumped.contains(r"C:\Users")); + assert!(!dumped.contains("Users")); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["actions"][0]["pathRejected"], json!(true)); + assert_eq!(item["commandRedacted"], json!(true)); + assert!(item.get("command").is_none()); + assert!(item.get("aggregatedOutput").is_none()); + } + + #[test] + fn mcp_art_brief_is_kept_and_result_is_dropped() { + let project = fixture_project("audit-art"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "id": "art-1", + "type": "mcpToolCall", + "server": "agc_tools", + "tool": "taonier_prepare_game_art", + "status": "completed", + "arguments": { "brief": "俯视角收集冒险小游戏", "mode": "reuse-or-create" }, + "result": { "secret": "do-not-store" }, + "error": null + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["brief"], json!("俯视角收集冒险小游戏")); + assert!(item.get("result").is_none()); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("do-not-store")); + let end = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!( + end["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + } + + #[test] + fn agc_write_file_keeps_path_and_content_chars_not_body() { + let project = fixture_project("audit-write"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { + "path": "game/index.html", + "content": "秘密正文" + } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["path"], json!("game/index.html")); + assert_eq!( + item["arguments"]["contentChars"], + json!("秘密正文".chars().count()) + ); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("秘密正文")); + assert!(item["arguments"].get("content").is_none()); + } + + #[test] + fn generate_image_prompt_is_truncated_with_hash() { + let project = fixture_project("audit-image"); + let prompt = "收".repeat(5000); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_generate_image", + "arguments": { "prompt": prompt, "kind": "icon-spec" } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let stored = item["arguments"]["prompt"].as_str().expect("prompt"); + assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS); + assert_eq!(item["arguments"]["promptChars"], json!(5000)); + assert_eq!( + item["arguments"]["promptSha256"], + json!(sha256_hex("收".repeat(5000).as_bytes())) + ); + } + + #[test] + fn file_change_keeps_path_and_kind_without_diff() { + let project = fixture_project("audit-patch"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "fileChange", + "status": "completed", + "changes": [{ + "path": "game/index.html", + "kind": { "type": "add" }, + "diff": "*** SECRET PATCH" + }] + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["changes"][0]["path"], json!("game/index.html")); + assert_eq!(item["changes"][0]["kind"], json!("add")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("SECRET PATCH")); + assert!(!dumped.contains("diff")); + } + + #[test] + fn list_or_search_does_not_count_as_reading_offered_attachment() { + let project = fixture_project("audit-list"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "rg fast_gdd assets", + "commandActions": [{ + "type": "search", + "query": "fast_gdd", + "path": "assets" + }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "ls assets/uploads", + "commandActions": [{ "type": "listFiles", "path": "assets/uploads" }] + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(false)); + assert!(end["offeredRead"][0].get("contentSha256Match").is_none()); + assert!(end["firstDesign"].is_null()); + } + + #[test] + fn first_design_skips_reads_and_uses_later_art_item_seq() { + let project = fixture_project("audit-order"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "commandActions": [{ "type": "read", "path": relative }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "收集冒险" } + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!(end["firstDesign"]["seq"], json!(2)); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + } + + #[test] + fn item_cap_writes_truncated_marker() { + let project = fixture_project("audit-cap"); + let mut audit = start_audit(project.path(), "x", &[]); + for index in 0..(DIRECT_CODEX_AUDIT_MAX_ITEMS + 2) { + audit.observe_item(&json!({ + "item": { + "id": format!("item-{index}"), + "type": "commandExecution", + "command": "ls", + "commandActions": [{ "type": "unknown" }] + } + })); + } + audit.finish(true); + let records = read_turn_log(project.path(), "turn-01"); + let items = records + .iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, DIRECT_CODEX_AUDIT_MAX_ITEMS); + assert!(records + .iter() + .any(|record| record["recordType"] == "direct.codex.items_truncated")); + let end = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["itemsTruncated"], json!(true)); + assert_eq!(end["itemCount"], json!(DIRECT_CODEX_AUDIT_MAX_ITEMS)); + } + + #[test] + fn agent_db_summary_points_at_relative_turn_log() { + let project = fixture_project("audit-db"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "俯视角收集冒险小游戏" } + } + })); + audit.finish(true); + let summary = read_agent_db(project.path()) + .into_iter() + .rev() + .find(|record| record["recordType"] == "direct.codex.turn") + .expect("summary"); + assert_eq!( + summary["turnLog"], + json!(format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/turn-01.jsonl")) + ); + assert_eq!( + summary["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + assert_eq!(summary["completed"], json!(true)); + assert!(!summary["turnLog"].as_str().expect("path").contains('\\')); + } + + #[test] + fn write_failure_does_not_panic_or_surface_through_finish() { + let project = fixture_project("audit-fail"); + fs::create_dir_all(project.path().join(".agent/runtime")).expect("runtime"); + fs::write( + project + .path() + .join(".agent/runtime/test-fail-direct-codex-audit"), + "1", + ) + .expect("marker"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "unknownTool" } + })); + audit.finish(true); + assert!(!project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl") + .is_file()); + } + + #[test] + fn unknown_item_type_keeps_only_public_fields() { + let project = fixture_project("audit-unknown"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "id": "mystery", + "type": "secretNewItem", + "payload": { "token": "leak-me" }, + "aggregatedOutput": "nope" + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["itemType"], json!("secretNewItem")); + assert_eq!(item["itemId"], json!("mystery")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("leak-me")); + assert!(!dumped.contains("payload")); + assert!(!dumped.contains("aggregatedOutput")); + } + + #[test] + fn agent_messages_are_skipped() { + let project = fixture_project("audit-skip"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "agentMessage", "text": "已按 GDD 完成" } + })); + audit.finish(true); + let items = read_turn_log(project.path(), "turn-01") + .into_iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, 0); + } +} 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 e515babc1..798bac52e 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 @@ -7,9 +7,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; -const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; -const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; -const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; @@ -2177,9 +2174,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { let sources = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| { - std::fs::read_to_string(root.join(relative_path)).ok() - }) + .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2267,9 +2262,7 @@ fn direct_browser_evidence_needs_art_repair( fn direct_game_output_completion_error(root: &Path) -> Option { let entry = agent_runtime_game_entry_relative_path(root); if !root.join(entry).is_file() { - return Some(format!( - "Codex 返回后未找到 {entry},项目未进入可运行状态" - )); + return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } if !direct_game_sources_reference_taonier_art_package(root) { return Some( @@ -3718,14 +3711,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String { .join("\n") } -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexHomeAttachment { - name: String, - media_type: String, - size: u64, -} - #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectCodexHomeReply { @@ -3758,78 +3743,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply { } } -fn direct_codex_home_attachment_name(value: &str) -> String { - let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); - let sanitized = basename - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS) - .collect::(); - if sanitized.is_empty() { - "未命名附件".to_string() - } else { - sanitized - } -} - -fn direct_codex_home_attachment_media_type(value: &str) -> String { - let value = value.trim(); - if value.is_empty() - || value.chars().any(|character| { - !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) - }) - { - "application/octet-stream".to_string() - } else { - value - .chars() - .take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS) - .collect() - } -} - -fn render_direct_codex_home_user_prompt( - prompt: &str, - attachments: &[DirectCodexHomeAttachment], -) -> Result { - let prompt = prompt.trim(); - if prompt.is_empty() && attachments.is_empty() { - return Err("聊天内容不能为空".to_string()); - } - if attachments.is_empty() { - return Ok(prompt.to_string()); - } - - let mut sections = Vec::new(); - if !prompt.is_empty() { - sections.push(prompt.to_string()); - sections.push(String::new()); - } - sections.push( - "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(), - ); - for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) { - sections.push(format!( - "- {};类型:{};大小:{} 字节", - direct_codex_home_attachment_name(&attachment.name), - direct_codex_home_attachment_media_type(&attachment.media_type), - attachment.size, - )); - } - if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS { - sections.push(format!( - "- 另有 {} 个附件未展开", - attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS - )); - } - Ok(sections.join("\n")) -} - pub(crate) async fn run_direct_game_creator_home_turn( prompt: &str, - attachments: &[DirectCodexHomeAttachment], + attachments: &[DirectCodexTurnAttachment], ) -> Result { - let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?; + let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) .await .map(parse_direct_codex_home_reply) @@ -3859,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt, creation_type, None, + None, ) .await } @@ -3868,6 +3787,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -3883,7 +3803,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None); } - match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await { + match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await + { Ok(reply) => Ok(reply), Err(failure) => { let error = record_direct_codex_turn_failure(root, failure); @@ -3900,6 +3821,7 @@ async fn run_direct_game_creator_turn_inner( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -3928,15 +3850,23 @@ async fn run_direct_game_creator_turn_inner( ); } }; - direct_game_creator_codex_chat_at_with_observer( + direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, prompt.to_string(), - &mut observer, + Some(&mut observer), + audit, ) .await } else { - direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + prompt.to_string(), + None, + audit, + ) + .await } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; if let Some(emitter) = turn_emitter { @@ -4263,6 +4193,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( prompt: String, creation_type: Option, client_turn_id: Option, + attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -4271,21 +4202,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + let mut audit = DirectCodexTurnAudit::start( root, + &turn_id, &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let user_prompt = match render_direct_codex_user_prompt( + &prompt, + attachments.as_deref().unwrap_or_default(), + ) { + Ok(prompt) => prompt, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + &user_prompt, creation_type.as_deref(), Some(&turn_emitter), + Some(&mut audit), ) - .await?; - persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| { + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + 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); - redact_agent_runtime_error( + 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) } @@ -4293,7 +4250,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, - attachments: Option>, + attachments: Option>, ) -> Result { run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } @@ -4495,47 +4452,6 @@ mod tests { assert!(!prompt.contains("game/index.html")); } - #[test] - fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { - let attachments = vec![DirectCodexHomeAttachment { - name: r"C:\Users\secret\角色参考.png".to_string(), - media_type: "image/png\nBearer secret".to_string(), - size: 3, - }]; - - let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments) - .expect("home prompt"); - - assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明")); - assert!(prompt.contains("角色参考.png")); - assert!(prompt.contains("类型:application/octet-stream")); - assert!(prompt.contains("大小:3 字节")); - assert!(!prompt.contains("C:\\Users")); - assert!(!prompt.contains("\nBearer secret")); - } - - #[test] - fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { - assert_eq!( - render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"), - "你好" - ); - let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2) - .map(|index| DirectCodexHomeAttachment { - name: format!("asset-{index}.png"), - media_type: "image/png".to_string(), - size: index as u64, - }) - .collect::>(); - let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments) - .expect("bounded attachments"); - assert!(prompt.contains("asset-7.png")); - assert!(!prompt.contains("asset-8.png")); - assert!(prompt.contains("另有 2 个附件未展开")); - assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok()); - assert!(render_direct_codex_home_user_prompt("", &[]).is_err()); - } - #[test] fn home_create_marker_is_accepted_only_as_the_first_reply_token() { let requested = parse_direct_codex_home_reply(format!( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 903debb7d..be8168f4e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -60,6 +60,7 @@ import type { GenerateLocalGameDraftResult, ImportCanvasExportResult, InitLocalProjectResult, + LauncherImportedAttachment, LimitedLocalCommandResult, ListLocalProjectFilesResult, LocalAgentMemoryResult, @@ -127,6 +128,10 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { + type DirectCodexTurnAttachment, + toDirectCodexTurnAttachments, +} from './features/app-shell/directCodexTurnAttachments'; import { isDeveloperMode, isTransientProjectOpenMessage, @@ -407,6 +412,7 @@ type AppProps = { supervisorChatOnly?: boolean; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( @@ -422,6 +428,13 @@ type AppProps = { onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; }; +type ExecuteChatAgentReplyInput = { + prompt: string; + clientTurnId?: string; + creationType?: HomeCreationType | null; + attachments?: DirectCodexTurnAttachment[]; +}; + export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, @@ -432,6 +445,7 @@ export function App({ supervisorChatOnly = false, initialSupervisorMessage = '', initialCreationType = null, + initialAttachments = [], playRequest = null, onPlayRequestHandled, onManifestChange, @@ -495,6 +509,7 @@ export function App({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), creationType: initialCreationType, + attachments: toDirectCodexTurnAttachments(initialAttachments), }); const handledPlayRequestRef = useRef(null); @@ -990,11 +1005,7 @@ export function App({ | null >(null); const executeChatAgentReplyRef = useRef< - ( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) => Promise + (input: ExecuteChatAgentReplyInput) => Promise >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); @@ -2753,10 +2764,10 @@ export function App({ return; } recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply( - unansweredDirectTurn.prompt, - unansweredDirectTurn.turnId, - ); + void executeChatAgentReply({ + prompt: unansweredDirectTurn.prompt, + clientTurnId: unansweredDirectTurn.turnId, + }); }); } } catch (error) { @@ -5225,7 +5236,7 @@ export function App({ return; } - void executeChatAgentReply(prompt); + void executeChatAgentReply({ prompt }); } async function executeLlmConfigStatus() { @@ -5372,11 +5383,12 @@ export function App({ } } - async function executeChatAgentReply( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) { + async function executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + creationType, + attachments, + }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -5486,6 +5498,7 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; + attachments?: DirectCodexTurnAttachment[]; } = { projectPath: directProjectPath, prompt, @@ -5494,6 +5507,9 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } + if (attachments?.length) { + directTurnInput.attachments = attachments; + } const reply = await directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -5811,11 +5827,12 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current( - latch.prompt, - directConversationTurnId, - latch.creationType, - ); + void executeChatAgentReplyRef.current({ + prompt: latch.prompt, + clientTurnId: directConversationTurnId, + creationType: latch.creationType, + attachments: latch.attachments, + }); }, [ chatAgentBusy, directCodexProductRuntime, @@ -10826,7 +10843,10 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply(prompt, directConversationTurnId); + void executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + }); } const visibleProfessionalAgentCards = agentStatusCards.filter( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 23474fb71..aaf922b5d 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -48,6 +48,7 @@ export type LauncherImportedAttachment = { localPath?: string; status: 'imported' | 'failed'; error?: string; + size?: number; }; export type LocalProjectKind = 'web' | 'godot'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 501eca0b1..f39ce5eb3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -339,6 +339,7 @@ export function WorkspaceLauncherShell({ initialProjectKind={currentProjectContext.projectKind} initialSupervisorMessage={currentProjectContext.initialPrompt} initialCreationType={currentProjectContext.creationType} + initialAttachments={currentProjectContext.attachments} orchestrationMode="single-supervisor" projectSupervisorOnly planningStartMode={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts new file mode 100644 index 000000000..ece47fd13 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts @@ -0,0 +1,38 @@ +import type { LauncherImportedAttachment } from '../../app/types'; + +export type DirectCodexTurnAttachment = { + name: string; + mediaType: string; + size?: number; + localPath?: string; + status?: 'imported' | 'failed'; +}; + +export function toDirectCodexTurnAttachments( + imported: readonly LauncherImportedAttachment[] | null | undefined, +): DirectCodexTurnAttachment[] { + if (!imported?.length) { + return []; + } + return imported.map((item) => { + const attachment: DirectCodexTurnAttachment = { + name: item.fileName, + mediaType: item.mediaType, + }; + if ( + typeof item.size === 'number' && + Number.isFinite(item.size) && + item.size >= 0 + ) { + attachment.size = Math.trunc(item.size); + } + const localPath = item.localPath?.trim(); + if (localPath) { + attachment.localPath = localPath; + } + if (item.status === 'imported' || item.status === 'failed') { + attachment.status = item.status; + } + return attachment; + }); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index eb8aa9f07..8d8283eba 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -5,7 +5,11 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { ChatMessage, LocalProjectDirectoryStatus } from '../../app/types'; +import type { + ChatMessage, + LauncherImportedAttachment, + LocalProjectDirectoryStatus, +} from '../../app/types'; import type { HomeCreationType } from '../../view/home'; import type { LauncherView } from '../../view/layout'; import type { @@ -32,6 +36,7 @@ export type ProjectSupervisorComponentProps = { initialProjectKind?: 'web' | 'godot'; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 366ddbf6f..df66b0f1b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -212,6 +212,7 @@ export function useHomeProjectCreation({ mediaType, localPath: result.localPath, status: 'imported', + size: attachment.file.size, }); } catch (error) { imported.push({ @@ -219,6 +220,7 @@ export function useHomeProjectCreation({ mediaType, status: 'failed', error: error instanceof Error ? error.message : String(error), + size: attachment.file.size, }); } } 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 520ff4666..a2fa4ed27 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1529,11 +1529,44 @@ export function registerHomeProjectCreationTests() { prompt: '按这个角色做游戏', creationType: 'game', clientTurnId: expect.any(String), + attachments: [ + { + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', expect.anything(), ); + + expect(await screen.findByText('附件已经进入当前项目。')).not.toBeNull(); + const followUpInput = screen.getByLabelText('陶泥儿对话内容'); + fireEvent.change(followUpInput, { target: { value: '再补一句玩法' } }); + fireEvent.submit(followUpInput.closest('form') as HTMLFormElement); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(2); + }); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + projectPath: automaticProjectPath, + prompt: '再补一句玩法', + clientTurnId: expect.any(String), + }); + const followUpPayload = invoke.mock.calls.find( + ([command, args]) => + command === 'chat_with_game_creator_direct_codex' && + (args as Record | undefined)?.prompt === + '再补一句玩法', + )?.[1] as Record | undefined; + expect(followUpPayload).not.toHaveProperty('attachments'); }); it('starts an automatic game project from the approved GDD', async () => { @@ -1730,6 +1763,18 @@ export function registerHomeProjectCreationTests() { } else { expect(startCall?.[1]).not.toHaveProperty('source'); } + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + '本轮用户附件', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); expect( invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', @@ -1738,6 +1783,98 @@ export function registerHomeProjectCreationTests() { }, ); + it('keeps 做方案 first turn on Supervisor without a Direct attachment sidecar', async () => { + const projectPath = '/tmp/home-planning-attachment'; + const manifest = createGameCreationAppManifest( + 'home-planning-attachment', + '首页策划附件', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + expectedRunProfile: 'standard', + }); + const fileBytes = Array.from(new TextEncoder().encode('png')); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'upload_local_asset') { + return { + id: 'asset-upload-plan-1', + localPath: 'assets/uploads/reference.png', + absolutePath: `${projectPath}/assets/uploads/reference.png`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const fileInput = + document.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + const attachment = new File(['png'], '角色参考.png', { + type: 'image/png', + lastModified: 1, + }); + Object.defineProperty(attachment, 'arrayBuffer', { + value: async () => new Uint8Array(fileBytes).buffer, + }); + fireEvent.change(fileInput!, { target: { files: [attachment] } }); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '整理一份可玩原型'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('整理一份可玩原型'); + expect(promptInput.textContent).toContain('角色参考.png'); + }); + fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ + projectPath, + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + }), + ); + }); + expect(invoke).toHaveBeenCalledWith('upload_local_asset', { + projectPath, + fileName: '角色参考.png', + mediaType: 'image/png', + bytes: fileBytes, + }); + const startCall = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + 'assets/uploads/reference.png', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); + }); + it('surfaces the planning clarification card after 做方案 creates the project from home', async () => { // 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run // 停在 waiting-for-user-input 并带回澄清请求,而工作台一直停在前端本地的占位文案, diff --git a/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts new file mode 100644 index 000000000..7ddc3a0c1 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { toDirectCodexTurnAttachments } from '../src/features/app-shell/directCodexTurnAttachments'; + +describe('toDirectCodexTurnAttachments', () => { + it('maps imported files and omits error plus empty localPath', () => { + expect( + toDirectCodexTurnAttachments([ + { + fileName: '角色参考.png', + mediaType: 'image/png', + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + size: 12, + }, + { + fileName: 'lost.bin', + mediaType: 'application/octet-stream', + status: 'failed', + error: '磁盘不可写', + size: 2, + }, + ]), + ).toEqual([ + { + name: '角色参考.png', + mediaType: 'image/png', + size: 12, + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + }, + { + name: 'lost.bin', + mediaType: 'application/octet-stream', + size: 2, + status: 'failed', + }, + ]); + }); + + it('returns an empty list when there is nothing to map', () => { + expect(toDirectCodexTurnAttachments(undefined)).toEqual([]); + expect(toDirectCodexTurnAttachments([])).toEqual([]); + }); +}); diff --git a/docs/README.md b/docs/README.md index f937a964d..40195c0ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 +- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5e0ee27db..2936ef7c9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -25,6 +25,22 @@ --- +## 2026-08-31 Direct 回合把 Codex item 落成有界行为账本 + +- 背景:sidecar 已让模型看见本轮附件路径,但 native 读 / MCP / 写文件只存在于隔离 `CODEX_HOME` 的瞬时 stdout,回合结束即删。无法判断「没读附件」还是「读了仍走默认收集类」。 +- 决策:GUI DirectProject 每个 `clientTurnId` 追加 `.agent/runtime/direct-codex/turns/.jsonl`,并在 `agent.db` 写一条 `direct.codex.turn` 摘要。记 sidecar 提供的路径与文件 hash、`item/completed` 的 Read/List/Search/MCP/写文件(不含 stdout、patch、MCP result),以及 `offeredRead` / `firstDesign`。审计 fail-open,不阻断做游戏。Home、CLI、Supervisor 收据模型不接。不灌附件正文,不强制读取,不为 GDD 开特例。 +- 影响范围:`direct_codex_audit.rs`、Direct GUI command 边界、Codex collect 循环;前端 / jsonl 气泡 / sidecar 文案不变。 +- 验证方式:Rust fixture 覆盖 turn_start hash、绝对路径相对化、stdout/diff 不落盘、art brief 保留、list/search 不算已读、firstDesign 顺序、256 条截断、写盘失败不 panic;sidecar 渲染与 Direct 活动词测试保持通过。 +- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、issue #212。 + +## 2026-08-31 Direct 本轮附件只映射路径,不灌正文、不区别 GDD + +- 背景:issue #212。首页附件已经复制到 `assets/uploads/` 并登记,但 Direct 首轮只把用户原文发给 Codex,原文件名不是磁盘路径,模型会另起一套玩法。 +- 决策:Home 与 Project 共用 `DirectCodexTurnAttachment`。有项目路径或导入状态时,只在发给 Codex 的 user prompt 末尾附有界 sidecar(原名 → 项目相对路径、类型、大小、状态);无路径且无状态时保持首页元数据文案。不灌正文、不强制读取、不按 GDD 开特例。做成游戏固定 prompt 不改,同一条 Direct 首轮附件链自动吃到 sidecar。jsonl 与工作台气泡仍只写用户原文。 +- 影响范围:`direct_codex_attachments.rs`、Direct command 边界、首页建项 latch、工作台首轮 invoke;Supervisor / 做方案首轮忽略附件 sidecar。 +- 验证方式:Rust 渲染测试(Home 逐字兼容、Project 映射、非法路径);home.suite 附件 Direct invoke 含 `localPath`;无附件不出现 `attachments` 键;做方案首轮仍走 Supervisor 且无 sidecar;后续手打消息不带 attachments。 +- 关联文档:`docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、issue #212。 + ## 2026-08-26 运行中自主扩图提案留在编排层 - 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。 @@ -39,6 +55,7 @@ - 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。 - 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。 + ## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` - 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 5fbb8fe37..db230fbc1 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,6 +1,6 @@ # 文档地图与阅读索引 -更新时间:`2026-08-25` +更新时间:`2026-08-31` ## 阅读顺序 @@ -24,9 +24,11 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` 3. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -4. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -5. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -6. UI 编辑器、宿主壳和当前测试专题文档 +4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +5. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +6. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +7. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +8. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md new file mode 100644 index 000000000..a0c1f88cb --- /dev/null +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -0,0 +1,262 @@ +# DirectProject 本轮附件路径映射 + +- 日期:2026-08-31 +- 状态:现行合同(已按本文落地) +- 问题:Gitea issue #212(DirectProject 未消费用户上传权威文档) +- 关联入口:PR #210「批准 GDD 回填做游戏入口」(`feat/create_entrance`,未合入时仍按该 PR 的调用链理解) +- 原则:落地后代码简洁可维护,不为了 diff 最小而打补丁;附件一律同等对待,不给 GDD 开协议特例 + +## 0. 一句话 + +首页带进项目的附件已经复制并登记,但 Direct 首轮只拿到用户原文。本方案让 Direct 回合在发给 Codex 的 user prompt 末尾附上**有界路径映射**(原文件名 → 项目相对路径),不灌正文、不强制读取、不改做方案注入。 + +## 1. 目标与非目标 + +### 目标 + +1. Direct 首轮知道本轮用户附件的原文件名、项目相对路径、媒体类型、大小和导入状态。 +2. 用户原文不被改写;路径映射是独立 sidecar。 +3. 图片、Markdown、其它文件走同一条协议。 +4. 做成游戏(PR #210:读 `game/fast_gdd.md` → 当成 `fast_gdd.md` 附件 → `createHomeDraftAutomatically`)自动吃到效果,因为那条链就是「带附件的 Direct 首轮」。 + +### 非目标 + +- 不改 PR #210 的按钮、固定 prompt、`startGameFromApprovedGdd`、读 `game/fast_gdd.md` 的方式。 +- 不改 `approvedGddRef`、审批 receipt、策划项目里的 `game/fast_gdd.md` 投影。 +- 不改上传命名 `assets/uploads/upload--`。 +- 不把附件全文拼进 prompt,不按扩展名决定是否读取。 +- 不把「没读到就阻断」做成门禁。 +- 不扫 manifest 里历史 `kind=uploaded`。 +- 不做 native 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接。 +- 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。 +- 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。 + +## 2. 现行断点 + +```text +Home 附件 / 做成游戏 File(fast_gdd.md) + → upload_local_asset + → LauncherProjectContext.attachments // 已有,只给资源画布 + → ProjectSupervisor // 无 attachments 字段 + → chat_with_game_creator_direct_codex + { projectPath, prompt, clientTurnId, creationType? } +``` + +做成游戏的固定 prompt 仍写「附件中的 `fast_gdd.md`」,磁盘文件却是 `assets/uploads/upload--fast_gdd.md`。映射没有进 Direct。 + +DirectHome 已有 `{ name, mediaType, size }` 元数据注入,但标明「尚未打开项目,内容尚不可读取」。项目已落盘后这条元数据被丢掉。 + +## 3. 目标合同 + +### 3.1 唯一 DTO + +Home 与 Project 共用一个附件结构,缺省字段表示 Home 现状: + +```ts +{ + name: string; // 原文件名 + mediaType: string; + size?: number; // 缺省按 0 + localPath?: string; // 仅已落入项目时出现 + status?: 'imported' | 'failed'; +} +``` + +- 不把 `error` 字符串送给模型。 +- 前端 `LauncherImportedAttachment` 继续给画布;invoke 前映射成上述瘦 DTO。 +- `importHomeAttachments` 把 `File.size` 写入可选 `size`,不改 `upload_local_asset` 返回值。 + +Rust: + +```rust +struct DirectCodexTurnAttachment { + name: String, + media_type: String, + #[serde(default)] + size: u64, + #[serde(default)] + local_path: Option, + #[serde(default)] + status: Option, // 只接受 imported | failed,其它忽略 +} +``` + +`DirectCodexHomeAttachment` 删除,Home command 改用同一类型。现有 Home JSON(无 `localPath` / `status`)继续能反序列化。 + +### 3.2 渲染 + +一个函数 `render_direct_codex_user_prompt(prompt, attachments) -> Result`: + +| 输入 | 输出 | +|---|---| +| 无附件 | `prompt.trim()`;若也空则 `Err("聊天内容不能为空")` | +| 附件都没有 `localPath` 且都没有 `status` | 保持现有 Home 文案与行格式,测试须逐字兼容 | +| 任一条有 `localPath` 或 `status` | Project 头 + Project 行格式 | + +Home 行(禁止改字): + +```text +[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取] +- {name};类型:{mediaType};大小:{n} 字节 +``` + +Project 头与行(禁止出现 GDD / 规格 / 权威 / 必须读取): + +```text +<用户原文> + +[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。] +- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported +``` + +规则: + +- 条数上限仍为 8,超出写 `- 另有 N 个附件未展开`。 +- 名字清洗沿用 Home:basename、去掉控制字符、最多 160 字、空则「未命名附件」。 +- 媒体类型清洗沿用 Home。 +- `localPath` 只接受项目相对 POSIX 路径:无 `..`、无盘符/根路径、首段不是 `.agent` / `.git`,`\` 归一为 `/`,最长 512;不合法则该条不输出路径,状态按 `failed`。 +- 有附件时允许原文为空(Home 已如此)。Direct 内层若仍要求非空,在 command 边界先渲染再下传,避免空原文 + 有附件被拒。 + +不在 sidecar 里写「若用户要求按附件实施请先读取」。意图留在用户原文;做成游戏的固定 prompt 已经在说这件事。 + +### 3.3 谁渲染、谁看见 + +- sidecar **只在进 Codex 前由 Rust 拼装**。 +- `.agent/conversations/project.jsonl` 继续写用户原文(现有 `append_local_conversation_message`)。 +- 工作台气泡继续显示 latch / 输入框原文,不把 sidecar 画进 UI。 +- 未完成首轮的 hydration 重放目前只带 prompt:本期不把附件写进 jsonl,进程重启后的未完成首轮可能丢映射。完整成功首轮不受影响。不为此新增会话 schema。 + +## 4. 数据流 + +```text +Home 上传 / 做成游戏 File + → upload_local_asset(已有) + → LauncherProjectContext.attachments(已有) + → ProjectSupervisor.initialAttachments + → 首轮 latch(与 prompt、creationType 同级) + → 仅 chat_with_game_creator_direct_codex.attachments + → render_direct_codex_user_prompt + → 现有 Direct turn(cwd = 项目根) +``` + +Supervisor / 做方案首轮忽略 `attachments`,行为不变。 + +后续工作台手打消息不带 `attachments`。附件是这一轮带来的,不是项目终身上下文。 + +## 5. 代码落地(按最终结构,不按最小补丁) + +### 5.1 Rust + +新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs): + +- DTO、清洗、上限常量、`render_direct_codex_user_prompt` +- 单元测试(见第 6 节) + +[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs) 增加 `mod direct_codex_attachments`。 + +[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs): + +- 删除 Home 专用 struct / sanitizer / `render_direct_codex_home_user_prompt` +- `run_direct_game_creator_home_turn` 改为调用共享渲染 +- `chat_with_game_creator_direct_codex` 增加 `attachments: Option>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` +- 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现 + +不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。 + +### 5.2 前端 + +[`model.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/model.ts) `ProjectSupervisorComponentProps` 增加: + +```ts +initialAttachments?: LauncherImportedAttachment[]; +``` + +[`WorkspaceLauncher.tsx`](../../apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx): + +```ts +initialAttachments={currentProjectContext.attachments} +``` + +[`App.tsx`](../../apps/ai-game-creator-shell/src/App.tsx): + +- props / latch 增加 `attachments`(默认 `[]`) +- `executeChatAgentReply` 不要再加第 5 个位置参数,收成: + +```ts +{ + prompt: string; + clientTurnId?: string; + creationType?: HomeCreationType | null; + attachments?: DirectCodexTurnAttachment[]; +} +``` + +- Direct invoke:有 `creationType` 才写该字段(现有);`attachments?.length` 才写 `attachments` +- Supervisor 分支完全不读 `attachments` +- 现有 `executeChatAgentReply(prompt)` / 恢复未完成 turn 的调用改为对象形式,不传 attachments + +瘦映射不要写在 1 万行的 `App.tsx` 里,放到例如 [`apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts):`toDirectCodexTurnAttachments(imported)`,去掉 `error`,空 `localPath` 不输出该键。 + +[`useHomeProjectCreation.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts):`importHomeAttachments` 写入 `size: attachment.file.size`。不改 `initialPrompt` 兜底句,不改做成游戏(即便本分支尚未合入 PR #210,也不预埋 GDD 字段)。 + +[`types.ts`](../../apps/ai-game-creator-shell/src/app/types.ts):`LauncherImportedAttachment` 增加可选 `size?: number`。 + +### 5.3 文档 + +落地提交时(不是本方案文件自身): + +- 本文件标为现行合同 +- `docs/README.md`、`docs/project-memory/shared-memory/document-map.md` 增加条目 +- `decision-log.md` 记一条:Direct 本轮附件只映射路径,不灌正文、不区别 GDD +- 不把 issue #212 技术说明改写成「已修复」,等代码合入后再改状态 + +## 6. 测试 + +### Rust(新文件) + +1. 无附件:原文 trim 后原样返回;空原文报错。 +2. Home 形态(无 path、无 status):与迁过来的两条现有测试逐字一致(含路径剥离、非法 mediaType、8 条上限)。 +3. Project 形态:原文保留;含原名与 `assets/uploads/...`;**断言不得出现「GDD」「规格」「权威」**。 +4. 同一列表里 png 与 md 行格式相同(只是 name/path/type 不同)。 +5. `status=failed` 且无 path:有状态、无项目路径、无 error 正文。 +6. 非法 `localPath`(`../`、`.agent/x`、绝对路径)不出现在输出中。 +7. 空原文 + 有附件:成功,且含 Project 头。 + +### 前端 + +1. [`home.suite.ts`](../../apps/ai-game-creator-shell/tests/appSurface/home.suite.ts)「imports home attachments…」:Direct invoke 必须带 `attachments`,其中 `name` 为 `角色参考.png`、`localPath` 为 upload 返回路径、`status: 'imported'`。用 png 证明不是 md 特例。 +2. 无附件的 Direct invoke 仍不得出现 `attachments` 键(或等价:不传该字段)。 +3. `planningStartMode` 首轮仍走 Supervisor,`chat_with_game_creator_agent` 的 payload 不含附件 sidecar。 +4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/prompt/clientTurnId`(及既有 creationType 规则),不带 attachments。 +5. 若本分支已能跑 PR #210 的 home.suite / plan-gdd 做成游戏用例:只断言它仍调用 `createHomeDraftAutomatically` / 仍使用原固定 prompt;**不要**给做成游戏加第二条附件协议。sidecar 由通用 Direct 断言覆盖。 + +### 不测 + +- 不把「必须生成弹幕射击」写成单测。 +- 不测 native `file.read` 是否进 `agent.db`。 + +## 7. 做成游戏为什么不用改 + +PR #210 `startGameFromApprovedGdd`: + +1. 读策划项目 `game/fast_gdd.md` +2. `new File([content], 'fast_gdd.md')` +3. `createHomeDraftAutomatically({ creationType: 'game', prompt: APPROVED_GDD_BUILD_PROMPT, attachments }, 'direct-build')` + +之后与首页拖一个 md 完全相同。sidecar 见到的是「原文件名 `fast_gdd.md` + 新项目 `assets/uploads/upload-…-fast_gdd.md`」。固定 prompt 继续说「读附件中的 fast_gdd.md」,映射补上真实路径。 + +## 8. 验收 + +1. 首页做游戏:上传任意文本或图片 + 一句话,Direct 首轮 prompt 含原名和 `assets/uploads/...`。 +2. 做成游戏(PR #210 合入后或该分支上):固定 prompt 一字不改,同时出现改写后的项目路径。 +3. 做方案首轮:Supervisor 行为与现在一致,无 sidecar。 +4. 无附件:Direct 入参与现在一致。 +5. `npm run check:encoding`、`git diff --check`、相关 `home.suite` / Direct Rust 测试通过。 + +## 9. 实现顺序 + +1. Rust 共享渲染 + 迁 Home 测试 + Direct command 接 `attachments` +2. 前端 latch / invoke / 映射 / `size` +3. 改 `home.suite` 附件断言 +4. encoding 与定向测试 +5. 合入时补 decision-log 与文档索引 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md new file mode 100644 index 000000000..bd84378b9 --- /dev/null +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -0,0 +1,389 @@ +# Direct 回合行为审计账本 + +- 日期:2026-08-31 +- 状态:现行合同(已按本文落地) +- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」 +- 关联:[`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`](./【技术方案】DirectProject本轮附件路径映射-2026-08-31.md)、[`【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md`](./【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md) +- 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar + +## 0. 一句话 + +Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动词,隔离 `CODEX_HOME` 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。 + +## 1. 目标与非目标 + +### 目标 + +一次带 `clientTurnId` 的 DirectProject GUI 回合结束后,只凭项目目录应能回答: + +1. 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的 `contentSha256`。 +2. 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。 +3. **顺序**:读附件是在第一次美术 brief / 第一次写 `game/` 之前还是之后。 +4. 第一次「定玩法」动作是什么(优先 `taonier_prepare_game_art.brief`,否则其它生成类 MCP 或对 `game/` 的写入)。 + +覆盖后续手打回合:只要 GUI Direct 有 `clientTurnId` 就记账本,附件可以为空。 + +### 非目标 + +- 不证明「理解并按 GDD 实施」。那是对照 `game/index.html`、美术产物做的产品判断;账本只提供行为时间线。 +- 不把 issue #212 原文的 NLP / 「关键内容进入上下文」做成自动判决。 +- 不灌附件正文进 prompt,不强制先读再继续,不为 GDD 开协议特例。 +- 不改 sidecar 文案、jsonl 用户原文、工作台气泡。 +- 不改做成游戏固定 prompt / PR #210 注入。 +- 不复用 Supervisor `agent.runtime.action_receipt` / `file.read`。 +- 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。 +- 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。 +- 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。 +- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。 +- 本期不改 UI,不在聊天面板展示审计。 +- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。 + +## 2. 现状 + +```text +Codex item/completed + commandExecution / mcpToolCall / fileChange / imageView / … + │ + ├─ 现用:收成 Activity("validation"|"controlled-tool"|…) + │ → Tauri 进度,不落盘 + └─ 不用:隔离 CODEX_HOME session(含 stdout)→ tempdir Drop 删除 +``` + +项目里现有: + +| 产物 | 记下的 | 缺的 | +|---|---|---| +| `.agent/conversations/project.jsonl` | 用户原文 + 助手终稿 | sidecar、工具调用 | +| `.agent/agent.db` | init / upload / 美术登记 / 对话指针 | native 读、MCP 调用、`agc_write_file` | +| `.agent/logs/command.log` | 权限确认 | 原生命令 | +| `asset.register` / `canvas.asset_generate` | 路径、切片、部分 `source.prompt` | 与读附件的先后 | +| 隔离 `CODEX_HOME` | Codex 自己的 session | 回合结束即删 | + +Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Read | ListFiles | Search | Unknown`,`Read.path` 在协议侧会拼成 cwd 绝对路径。Direct cwd 就是项目根(`resolve_direct_codex_project_authority` 不再强制 `game/` 子目录)。抽取时把绝对路径收回项目相对 POSIX,失败则丢路径,不写宿主绝对路径。 + +`agc_write_file` 经 tool bridge 落盘,当前不写 `agent.db`。不给每个 MCP 单独打点;统一在 `item/completed` 抽一次。 + +## 3. 分析用判据(相对 issue 收窄) + +落地后,对类似 `gameagent-9baa5293` 的 run,应能三分: + +| 时间线 | 结论 | 下一刀不该打哪 | +|---|---|---| +| `offeredRead.read=false`,`firstDesign` 已是 `taonier_prepare_game_art` 且 brief 是收集类 | 没打开附件就定了玩法 | 不是「GDD 解析不够」 | +| 先 `Read` 且 hash 对上,brief 仍是收集类 | 读了但没用 | sidecar 已够;看四切片 / icon-spec「收集物」/ 完成合同 | +| 只有 `ListFiles` / `Search` 命中 uploads,没有 `Read` | 发现了没读正文 | 映射可能够,缺的是读 | +| `Read` 的 path 是 `fast_gdd.md` 而不是 `assets/uploads/…` | sidecar 没被当成磁盘路径 | 还是路径合同 | + +不在账本里写「已遵循 GDD」或「未遵循 GDD」布尔。 + +## 4. 落点 + +两层,都在项目 `.agent/` 控制面内,模型读不到: + +1. **权威时间线**(每回合一个 jsonl,只追加) + `.agent/runtime/direct-codex/turns/.jsonl` +2. **总索引一条摘要**(方便继续翻现有 `agent.db`) + `recordType: "direct.codex.turn"` + +`clientTurnId` 沿用现有规则:trim 后 6–160 位 ASCII 字母数字或连字符,首位字母或数字。文件名用规范化后的 id,不再二次编码。 + +不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。 + +jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。 + +## 5. 记录合同 + +camelCase JSON。禁止出现附件正文、命令 stdout、patch diff、宿主绝对路径、Token、URL 签名。 + +### 5.1 `turn_start` + +在 sidecar **已经渲染之后**、Codex turn **启动之前**写入。`promptSha256` 哈希的是 **用户原文**(command 入参 `prompt`),不是带 sidecar 的全文。 + +```json +{ + "recordType": "direct.codex.turn_start", + "clientTurnId": "Abc123-def", + "sidecarPresent": true, + "promptSha256": "", + "promptChars": 120, + "attachments": [ + { + "name": "fast_gdd.md", + "localPath": "assets/uploads/upload-1788164530559-fast_gdd.md", + "mediaType": "text/markdown", + "size": 8119, + "status": "imported", + "contentSha256": "", + "hashSkipped": null + } + ] +} +``` + +- `attachments` 清洗复用 sidecar:`sanitize_attachment_name` / `media_type` / `status` / `local_path`。把这些函数改成 `pub(crate)`,审计模块不要复制一份。 +- 条数上限仍 8;超出只在 sidecar 文案里写「另有 N 个未展开」,账本 `attachments` 同样只留前 8,另加 `attachmentsOmitted: N`。 +- `sidecarPresent`:本轮渲染走了 Project 头(任一条有合法 path 或 status)。Home 形态不会出现在本账本(Home 不记账)。 +- `contentSha256`:对清洗后的 `localPath` 读项目文件做 SHA-256 小写 hex。文件不存在则省略 hash,`hashSkipped: "missing"`。超过 `DIRECT_CODEX_AUDIT_HASH_MAX_BYTES`(2 MiB)则 `hashSkipped: "too-large"`。`.agent` / `.git` / `..` 路径本来就不会出现在 sidecar 输出里。 + +无附件:`attachments: []`,`sidecarPresent: false`,仍然写 `turn_start`。 + +### 5.2 `item` + +仅 `item/completed`。`item/started` 和 `outputDelta` 不落盘。 + +公共字段: + +```json +{ + "recordType": "direct.codex.item", + "clientTurnId": "Abc123-def", + "seq": 1, + "itemId": "item-…", + "itemType": "commandExecution", + "status": "completed" +} +``` + +`seq` 从 1 起,按成功写入的 item 递增。`itemType` 取 Codex `item.type` 原词;未知类型仍记账 `itemType`,不附带未清洗 payload。 + +按类型附加字段: + +| `item.type` | 追加 | 禁止 | +|---|---|---| +| `commandExecution` | `command` 截断 240 字;`exitCode`;`durationMs`;`actions[]` | `aggregatedOutput` | +| `mcpToolCall` | `tool`、`server`(可省略默认 `agc_tools`)、`durationMs`、§5.4 参数 | `result`、`error` 原文(只留 `status` / `errorKind`) | +| `fileChange` | `changes: [{ path, kind }]`,`kind` 为 `add` / `delete` / `update` | `diff`、`movePath` 的宿主绝对路径(相对化失败则整条 change 丢 path) | +| `imageView` | `path` | 图像字节 | +| `functionCallOutput` | `name`、`namespace` | `output` | +| `webSearch` | `query` 截断 400 字 | 结果页正文 | +| `agentMessage` / `userMessage` / `plan` / `reasoning` / `contextCompaction` / `hookPrompt` | **整类跳过**(终稿已在 jsonl;推理正文不是本账本) | — | +| 其它未知 | 只留公共字段 | 原始 `item` 对象 | + +`commandExecution.actions[]`: + +```json +{ "type": "read", "path": "assets/uploads/upload-…-fast_gdd.md", "contentSha256": "…", "hashSkipped": null } +{ "type": "listFiles", "path": "assets" } +{ "type": "search", "query": "fast_gdd", "path": null } +{ "type": "unknown" } +``` + +- `Read.path` 先相对化再清洗;失败则该 action 记 `{ "type": "read", "pathRejected": true }`,不写绝对路径。 +- 相对化成功后,对磁盘文件按 §5.1 同一套 hash 规则补 `contentSha256`。 +- `command` 里若相对化失败,把 `command` 整段丢掉,改 `commandRedacted: true`(避免 `type C:\Users\…\fast_gdd.md` 进账本)。 + +### 5.3 MCP 参数白名单 + +只抄这些键,其它键丢弃。字符串再经 path 清洗或截断。 + +| 工具 | 落盘参数 | 正文类字段 | +|---|---|---| +| `agc_list_project_files` | `path`、`query`(120)、`kind`、`offset`、`limit` | 无 | +| `agc_write_file` | `path`、`contentChars`(`content` 的字符数,不是正文) | 不落 `content` | +| `taonier_prepare_game_art` | `mode`、`brief`(截断 4000)、`briefChars`、`briefSha256` | **要 brief 原文**(分析定玩法的吸烟枪;上限已是 MCP 合同) | +| `agc_generate_image` | `kind`、`aspectRatio`、`imageSize`、`assetName`、`outputPath`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 不落 32k 全文 | +| `agc_edit_image` | `sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 同上 | +| `agc_create_or_derive_resource` | `kind`、`mode`、`sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | MCP 上限已是 4000 | +| `agc_list_registered_assets` | `kind`、`assetId`、`includeSequenceFrames`、`offset`、`limit` | 无 | +| `agc_list_account_assets` | `folderId`、`query`、`offset`、`limit` | 无 | +| `agc_import_account_assets` | `assetIds`(最多 8 个 id,超出 `assetIdsOmitted`)、`localPaths`(清洗后相对路径,最多 8) | 无 | +| `agc_remove_background` | `sourceLocalAssetId`、`assetName` | 无 | +| `agc_browser_playtest` | `attempt` | 无 | +| `agc_web_search` | `query` 截断 400、`maxResults` | 无 | +| `agc_read_skill_resource` | `skillName`、`relativePath` | 不落 Skill 正文 | +| 未知 MCP 名 | 只留 `tool` + `status` | 不落 `arguments` | + +`brief` / 截断后的 `prompt` 是 **模型自己写的设计文本**,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。`agent.db` 摘要只留 `briefPreview` 240 字。 + +### 5.4 `turn_end` + +派生摘要,不是第二真相。字段必须能从本文件已写入的 `turn_start` + `item` 重算出来。 + +```json +{ + "recordType": "direct.codex.turn_end", + "clientTurnId": "Abc123-def", + "completed": true, + "itemCount": 17, + "itemsTruncated": false, + "offeredRead": [ + { + "localPath": "assets/uploads/upload-1788164530559-fast_gdd.md", + "read": false + } + ], + "firstDesign": { + "kind": "mcp:taonier_prepare_game_art", + "seq": 3, + "tool": "taonier_prepare_game_art", + "briefPreview": "俯视角收集冒险小游戏…" + } +} +``` + +`offeredRead.read=true` 当且仅当本 attempt 内存在 `actions.type=read` 或 `imageView` 或 MCP 参数里的 `path` / `localPaths`,清洗后与 `localPath` 字符串相等。hash 对不上仍记 `read: true`,另加 `contentSha256Match: false`(读了另一份同路径文件或读时文件已变)。没有 hash 可对则省略 `contentSha256Match`。 + +`firstDesign`:本 attempt 第一条满足任一条件的 item: + +1. MCP:`taonier_prepare_game_art` / `agc_generate_image` / `agc_edit_image` / `agc_create_or_derive_resource` +2. `agc_write_file` 且 path 以 `game/` 开头或文件名是 `index.html` +3. `fileChange` 且任一条 change path 满足 2 + +列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search **不算** firstDesign。没有则 `firstDesign: null`。 + +`kind` 取值:`mcp:` / `write:` / `fileChange:`。 + +### 5.5 `agent.db` 摘要 + +```json +{ + "recordType": "direct.codex.turn", + "clientTurnId": "Abc123-def", + "turnLog": ".agent/runtime/direct-codex/turns/Abc123-def.jsonl", + "sidecarPresent": true, + "offeredCount": 1, + "offeredRead": [ { "localPath": "assets/uploads/…-fast_gdd.md", "read": false } ], + "firstDesign": { "kind": "mcp:taonier_prepare_game_art", "seq": 3, "briefPreview": "…" }, + "itemCount": 17, + "itemsTruncated": false, + "completed": true, + "auditWriteFailed": false +} +``` + +`turnLog` 必须是项目相对 POSIX。不要把 jsonl 全文复制进 `agent.db`。单条仍受 Ordinary 1 MiB 限制;摘要本身应远小于此。 + +### 5.6 上限 + +| 项 | 值 | +|---|---| +| 每回合 item 条数 | 256;超出再写一条 `recordType: "direct.codex.items_truncated"`,之后 item 丢弃但仍把 `turn_end.itemsTruncated=true` | +| `command` | 240 字 | +| `brief` / 生成类 `prompt` 落盘 | 4000 字 | +| `briefPreview` | 240 字 | +| 文件 hash | 2 MiB | +| 附件条数 | 8(与 sidecar 相同) | +| jsonl 单行 | 沿用现有 jsonl 追加上限;超长截断正文类字段,不截断结构 | + +## 6. 调用链 + +```text +chat_with_game_creator_direct_codex + 规范化 clientTurnId + DirectCodexTurnAudit::start(root, clientTurnId, originalPrompt, attachments) + → 写 turn_start(fail-open) + render_direct_codex_user_prompt // 现有 sidecar,不变 + run_direct_game_creator_turn_at_with_creation_type_and_emitter(..., audit) + → Codex collect 循环在 DirectProject + item/completed 调 audit.observe_item + Ok/Err 都 audit.finish(completed) + → 写 turn_end + agent.db 摘要 +``` + +- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。 +- Home command 不接 audit。 +- 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。 +- `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。 + +`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject` 且 `audit` 为 Some 时抽取。 + +`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`。 + +回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`。 + +## 7. 失败语义 + +审计 **不得** 把做游戏打失败。所有写盘包在 sink 内: + +- 单次追加失败:记内存 `audit_write_failed=true`,后续 item 仍尝试写;`finish` 时摘要带 `auditWriteFailed: true`。 +- 连摘要都写不进去:只在 Direct debug / 现有进度通道能承受的前提下忽略;不新增用户可见报错文案。 +- 不引入新的 Tauri 事件名。 + +与 `conversation.write` 失败不同:助手终稿落盘失败仍按现有逻辑拒绝返回。审计失败不走那条。 + +## 8. 安全 + +- 路径:与 sidecar 同一套相对 POSIX 清洗;相对化失败不写原绝对路径。 +- 控制面:hash / 读文件只用 `resolve_local_project_path`;拒绝 `.agent` / `.git` / 敏感文件。这些路径若出现在 commandActions 里,只记 `pathRejected`。 +- 不把 `aggregated_output`、MCP result、function output、diff 暂存在内存再截断——抽取函数根本不读这些键。 +- jsonl 位于 `.agent/runtime/**`,现有 Direct 控制面边界禁止模型当普通项目文档读。 +- 前端观察者和审计 sink 分叉,禁止图省事 `observer(Item { params })`。 + +## 9. 代码落地 + +新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs): + +- `DirectCodexTurnAudit` +- `start` / `observe_item` / `finish` +- 相对化、hash、MCP 白名单、`firstDesign` / `offeredRead` 派生 +- 单元测试(见 §11) + +[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs):`mod direct_codex_audit` + `pub(crate) use`。 + +[`direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):清洗函数改 `pub(crate)`,行为不变。 + +[`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs): + +- `run_turn_with_direct_observer` / `direct_game_creator_codex_chat_at_with_optional_observer` 增加 `audit: Option<&mut DirectCodexTurnAudit>` +- collect 循环 `Item { completed: true, .. }` 且 DirectProject 时 `audit.observe_item(¶ms)` +- **不要** 把 `params` 塞进 `DirectCodexTurnObservation` +- 现有 Home / ToolHost / 安全活动词测试保持逐字 + +[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs): + +- `chat_with_game_creator_direct_codex` 创建 audit(原文 + attachments),Ok/Err 都 `finish` +- 把 audit 传入 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` +- 该函数和 inner 增加可选 audit;CLI 入口签名不变 + +不要给 `agc_write_file` / 每个 MCP handler 再写一份平行审计。 + +前端、DTO、sidecar 文案、home.suite 附件断言:本期不改。不新增 UI。 + +文档:落地提交时把本文状态改为「现行合同(已按本文落地)」;`decision-log.md` 记一条;不要把 08-30 技术说明改成已修复。 + +## 10. 测试 + +全部是 Rust 单元测试,用 fixture item JSON,不拉真 Codex。 + +1. `turn_start`:原文 hash 稳定;sidecar 路径清洗后出现;非法 `../` 不进 attachments;无附件 `sidecarPresent=false`。 +2. 附件文件写入临时项目后 `contentSha256` 与直接 hash 一致;缺文件 `hashSkipped=missing`;超过 2 MiB `too-large`。 +3. `commandExecution` + `commandActions: [{type:read, path: }]` → 相对路径 + hash;`aggregatedOutput` 即使在 fixture 里也不出现在落盘 JSON。 +4. `Read` 相对化失败 → `pathRejected`,落盘 JSON 不含 `C:\\` / `Users`。 +5. `mcpToolCall` `taonier_prepare_game_art`:`brief` 保留;`result` 丢掉。 +6. `agc_write_file`:有 path 与 `contentChars`,无 content。 +7. `agc_generate_image`:32k prompt 只留 4000 + `promptChars` + sha256。 +8. `fileChange`:path + kind,无 diff。 +9. `offeredRead`:读路径等于 offered → `read=true`;只 list/search → `read=false`。 +10. `firstDesign`:先 read 再 art → kind 是 mcp art,seq 是 art 那条;只有 read → `null`。 +11. 第 257 条 item 触发 truncated,`turn_end.itemsTruncated=true`。 +12. `agent.db` 摘要含 `turnLog` 相对路径、`offeredRead`、`firstDesign.briefPreview`。 +13. 写盘注入失败:`finish` 不 panic、不返回 Err 给调用方(sink 方法是 `()`)。 +14. 未知 `item.type` 只留公共字段。 +15. 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。 + +不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。 + +## 11. 验收(方案落地后的人工分析) + +用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目: + +1. 存在 `.agent/runtime/direct-codex/turns/.jsonl`。 +2. `agent.db` 有对应 `direct.codex.turn`。 +3. `turn_start.attachments[].localPath` 与 sidecar 项目路径一致。 +4. jsonl **没有** GDD 正文、没有 `aggregatedOutput`、没有 patch。 +5. 能根据 `offeredRead` + `firstDesign` 填上 §3 四行表的其中一行,而不用猜隔离 session。 +6. 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。 +7. `npm run check:encoding`、`git diff --check`、相关 Rust 单测通过。 + +## 12. 实现顺序 + +1. `direct_codex_audit.rs` + 清洗函数 `pub(crate)` + fixture 测试 +2. `codex_app_server` collect 接 sink;观察者枚举不变 +3. GUI Direct command 创建 / finish sink +4. encoding 与定向 `cargo test` +5. 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文 + +## 13. 与 sidecar / issue 的边界 + +- sidecar:让模型 **知道路径**。已落地,合同不变。 +- 本账本:让人 **看见模型做了什么**。不替代 sidecar,也不在本方案里做强制读取。 +- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。 diff --git a/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md b/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md index 4de2f960b..4ac032b68 100644 --- a/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md +++ b/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md @@ -206,6 +206,7 @@ Direct app-server 使用 ephemeral thread。stdout 中的 `item/started`、`item - 策划 run ID:`gameagent-cd7f6c81` - 做游戏 run ID:`gameagent-77b5aa31` - 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md` +- 审计账本方案(已落地,不关闭本 Issue):[`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) - 建议随 Issue 附上或引用对应 run 的以下复核材料: - Direct 对话:`.agent/conversations/project.jsonl` - Direct Agent DB:`.agent/agent.db`