Merge pull request '修复 DirectProject 流式正文逐 delta 脱敏吃掉段尾换行导致 Markdown 结构损坏' (#533) from fix/direct-delta-markdown into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / Backend tests (push) Successful in 4m6s
Project CI / Frontend tests (push) Successful in 2m10s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m53s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m22s
Project CI / AI game creator shell web tests (push) Successful in 1m44s
Project CI / Repository checks (push) Successful in 2m13s

Reviewed-on: #533
This commit was merged in pull request #533.
This commit is contained in:
2026-09-29 21:49:44 +08:00
3 changed files with 63 additions and 17 deletions
@@ -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。
@@ -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 {
@@ -1,5 +1,14 @@
# 踩坑与排障记录
## 2026-09-29 逐 delta 脱敏会吃掉段尾换行:DirectProject 汇报的 Markdown 表格整块失效
- **现象**:AGC 项目对话里 agent 汇报正文渲染错位——段落并进同一行、序号项挤成一段、Markdown 表格整块不渲染(表头 `| 素材 | 用途 | 路径 |`、`|---|---|---|` 与数据行以纯文本连在同一行里);同一段文本里的路径还会出现 `game<absolute-path>ame.js`、`assets<absolute-path>anvas-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"` → `assets<absolute-path>anvas-generated`)。它既是内容损坏,又让流式正文**比完成态更长**(2 字符 → `<absolute-path>` 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 待读路径来抹掉待检测链接。