diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 0a5995554..7a2bb1091 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -6006,6 +6006,35 @@ mod tests { } } + /// 流式脱敏必须保留增量自带的换行:前端把每个 `item.delta` 的脱敏结果**直接拼接**成一条 + /// 消息再交给 Markdown 渲染,所以「逐段脱敏」必须与「整段脱敏」同形,不能吃掉段尾换行。 + /// + /// 回归背景:`sanitize_error_context` 曾用 `lines()` + `join("\n")` 往返,把以换行结尾的段 + /// 的末尾换行吃掉。真实会话里段落、列表项和表格行会因此并进同一行(表头、`|---|---|` 与 + /// 数据行挤成一段正文),整条汇报的 Markdown 结构——尤其表格——直接失效。 + #[test] + fn direct_thread_delta_sanitization_preserves_line_breaks() { + let root = std::path::Path::new("/workspace/direct-project"); + let chunks = [ + "共 4 项验收要求。\n", + "\n", + "| 素材 | 用途 |\n", + "|---|---|\n", + "| 飞鸟角色 | 玩家角色 |\n", + ]; + let streamed: String = chunks + .iter() + .map(|chunk| direct_thread_delta_text(root, chunk)) + .collect(); + let whole = chunks.concat(); + assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行"); + assert_eq!( + direct_thread_delta_text(root, &whole), + streamed, + "逐段脱敏与整段脱敏必须同形" + ); + } + /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 /// /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 8756e2212..5aa5afb51 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -623,34 +623,42 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// results. This intentionally does not call `sanitize_prompt_context`: the /// latter is stricter by design and would erase useful HTTP status/code/field /// information whenever a safe error line mentions a credential field. +/// +/// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用 +/// (`direct_thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 +/// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行 +/// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。 pub(crate) fn sanitize_error_context(value: &str) -> String { - let mut sanitized = Vec::new(); + let mut sanitized = String::with_capacity(value.len()); let mut inside_private_key = false; - for line in value.lines() { + for chunk in value.split_inclusive('\n') { + let terminated = chunk.ends_with('\n'); + let line = chunk.strip_suffix('\n').unwrap_or(chunk); + let line = line.strip_suffix('\r').unwrap_or(line); let lower = line.to_ascii_lowercase(); if contains_sensitive_cli_flag(&lower) { - sanitized.push("[redacted sensitive context]".to_string()); - continue; - } - if inside_private_key { + sanitized.push_str("[redacted sensitive context]"); + } else if inside_private_key { if lower.contains("-----end") && lower.contains("private key") { inside_private_key = false; } + // 私钥块整行吞掉(连带它的换行),与旧口径一致,不补空行。 continue; - } - if lower.contains("-----begin") && lower.contains("private key") { - sanitized.push("[redacted sensitive context]".to_string()); + } else if lower.contains("-----begin") && lower.contains("private key") { + sanitized.push_str("[redacted sensitive context]"); inside_private_key = !(lower.contains("-----end") && lower.contains("private key")); - continue; + } else { + let line = redact_secret_tokens(line); + let line = redact_error_sensitive_assignments(&line); + let line = redact_error_bearer_values(&line); + let line = redact_error_config_names(&line); + sanitized.push_str(&redact_secret_tokens(&line)); + } + if terminated { + sanitized.push('\n'); } - - let line = redact_secret_tokens(line); - let line = redact_error_sensitive_assignments(&line); - let line = redact_error_bearer_values(&line); - let line = redact_error_config_names(&line); - sanitized.push(redact_secret_tokens(&line)); } - sanitized.join("\n") + sanitized } fn error_key_boundary(lower: &str, start: usize, end: usize) -> bool { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 26c2e3a52..3f7f502b3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-29 逐 delta 脱敏会吃掉段尾换行:DirectProject 汇报的 Markdown 表格整块失效 + +- **现象**:AGC 项目对话里 agent 汇报正文渲染错位——段落并进同一行、序号项挤成一段、Markdown 表格整块不渲染(表头 `| 素材 | 用途 | 路径 |`、`|---|---|---|` 与数据行以纯文本连在同一行里);同一段文本里的路径还会出现 `gameame.js`、`assetsanvas-generated/...` 这种被切坏的占位符。重开项目读历史时同一段话渲染正常,很容易被当成渲染器偶发或模型写坏了 Markdown。 +- **原因**:两处叠加。① `#384`(`30c377d0f`)把 DirectProject 流式正文改成**每个 delta 各自脱敏**(`direct_codex_thread_delta_event` → `direct_thread_delta_text` → `sanitize_detail_text`),而 `sanitize_error_context` 用 `value.lines()` 遍历再 `join("\n")` 回填:**以换行结尾的段,末尾换行被吃掉**(`"a\n"` → `"a"`、`"\n"` → `""`)。前端 `appendLiveText` 是裸拼接,于是段落空行、列表项换行、表格行换行全部消失。本机 `gameagent-cfc64e7f` 的真实汇报按「每行一个 delta」拼接实测:1445 字符 / 38 个换行 → 1435 字符 / **0 个换行**,渲染成 0 个 `table`、0 个 `li`、0 个 `h2`(整条消息只剩一个段落)。② 同一函数里 `redact_absolute_path_tokens` 把 `index == 0` 当文本边界:delta 从 `/` 开头时,片段被误判成绝对路径(`"assets" + "/c" + "anvas-generated"` → `assetsanvas-generated`)。它既是内容损坏,又让流式正文**比完成态更长**(2 字符 → `` 15 字符),而 `mergeDirectChatEntry` 的正文合并规则是「取更长的一份」,于是 `item.completed` 送来的正确快照再也覆盖不回界面——只有重开项目(读 `project.jsonl` 原文经整段脱敏)才恢复。 +- **处理(本次)**:`sanitize_error_context` 改为 `split_inclusive('\n')` 逐段处理、原样保留行终止符(CRLF 仍归一成 LF,私钥块整行吞掉的旧口径不变)。逐段脱敏因此与整段脱敏同形,Markdown 结构不再依赖 delta 的切法。 +- **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。 +- **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/direct_thread_wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 + ## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 - 项目外读取不能只依赖末段 `O_NOFOLLOW`:父目录符号链接可隐藏 `.ssh` 等受保护名字。文件读取和目录列表在访问前逐段检查原始路径,拒绝符号链接与 Windows 重解析点;目录扫描对子目录再次检查。系统临时目录若含平台别名(例如 macOS `/var`),普通读取测试使用临时目录的 canonical 路径,不能通过 canonicalize 待读路径来抹掉待检测链接。