From f0d5687ab7af992a82660421de637072108fef6e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 18:36:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=9A=84=20project.jsonl=20?= =?UTF-8?q?=E8=AE=A9=E4=B8=A4=E6=9D=A1=E5=AF=B9=E8=AF=9D=E9=93=BE=E4=BA=92?= =?UTF-8?q?=E8=AF=BB=E5=AF=B9=E6=96=B9=E7=9A=84=E8=A1=8C=EF=BC=8C=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E5=8E=86=E5=8F=B2=E4=B8=8D=E5=86=8D=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project/conversation.rs:read_persisted_local_conversation_records_unlocked 跳过 type=response_item 且带 payload 的行(DirectProject 写给同一份「项目主对话」的行),其余解析失败仍按「解析对话记录失败」失败关闭 - 新增 is_direct_project_history_row 做行信封白名单,只认对方那一种明确枚举的形状,不放宽成「什么都跳过」 - 不把 response_item 行二次投影成 LocalConversationMessageRecord:DirectProject 侧已经拥有那份 Responses item → 聊天内容的投影,再造一份平行投影会与它漂移 - 不加「文件已属于 DirectProject 就拒绝追加旧行」的硬报错:runtime_driver/task_start.rs 在 ensure_..._accepted_public_status_at 返回 Err 时会中止后台任务(「后台任务启动确认落盘失败,任务未执行」),runtime_protocol/steering.rs:572/604/1300 也用 ? 上抛,加硬报错会把旧行噪声换成任务起不来;毒化本身已由读侧白名单堵住 - 补断言:混合文件里通用链只读自己的 legacy 行且坏行仍失败关闭、混合文件从两条链都能读(DirectProject 把两种行都读出来)、纯旧格式文件的写入行为不变 --- .../src-tauri/src/project/conversation.rs | 24 +++- .../src/project/conversation/tests.rs | 111 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index db55bb1c1..f8a6ff808 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -958,8 +958,19 @@ fn read_persisted_local_conversation_records_unlocked( if line.is_empty() { continue; } - let record = serde_json::from_str::(line) - .map_err(|error| format!("解析对话记录失败:{}: {error}", path.display()))?; + let record = match serde_json::from_str::( + line, + ) { + Ok(record) => record, + // 项目主对话(agent_id=None)与 DirectProject 共用同一份 + // `project.jsonl`:DirectProject 写的是 Codex rollout 的 + // `response_item` 行。通用对话链只认自己的 legacy 行,遇到对方那种 + // 行就跳过;其余任何坏行继续失败关闭。 + Err(_) if is_direct_project_history_row(line) => continue, + Err(error) => { + return Err(format!("解析对话记录失败:{}: {error}", path.display())) + } + }; records.push(record); } } @@ -969,6 +980,15 @@ fn read_persisted_local_conversation_records_unlocked( Ok(records) } +/// 只认 DirectProject 那一种明确枚举的行信封:`type=response_item` 且带 `payload`。 +/// 其余任何形状都不算“对方的行”,仍由上方按坏行失败关闭。 +fn is_direct_project_history_row(line: &str) -> bool { + serde_json::from_str::(line).is_ok_and(|parsed| { + parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item") + && parsed.get("payload").is_some() + }) +} + fn persisted_local_conversation_message_index_by_id( records: &[PersistedLocalConversationMessageRecord], expected_agent_id: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs index 1f257468f..288f2e84c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs @@ -225,3 +225,114 @@ fn finalization_conversation_retry_rejects_conflicting_audit_payload() { std::fs::remove_dir_all(root).ok(); } + +const DIRECT_PROJECT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-1","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; +const LEGACY_PROJECT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已创建菜单","agentId":null,"updatedAt":1757000000}"#; + +fn project_conversation_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/project.jsonl") +} + +fn write_project_history(root: &Path, lines: &[&str]) { + let path = project_conversation_path(root); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write project history"); +} + +fn append_project_message(root: &Path, content: &str) -> Result { + append_local_conversation_message_at( + root, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + ) +} + +#[test] +fn project_history_skips_direct_project_rows_but_keeps_other_broken_rows_failing_closed() { + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "混合行形状读取测试").expect("init project"); + write_project_history(&root, &[DIRECT_PROJECT_ROW, LEGACY_PROJECT_ROW]); + + let conversation = + read_local_conversation_at(&root, None).expect("read mixed project conversation"); + assert_eq!(conversation.messages.len(), 1); + assert_eq!(conversation.messages[0].role, "assistant"); + assert_eq!(conversation.messages[0].content, "已创建菜单"); + + write_project_history(&root, &[LEGACY_PROJECT_ROW, r#"{"unexpected":true}"#]); + let error = read_local_conversation_at(&root, None) + .expect_err("unrecognized row must keep failing closed"); + assert!(error.starts_with("解析对话记录失败"), "{error}"); + + std::fs::remove_dir_all(root).ok(); +} + +#[test] +fn mixed_project_history_rows_stay_readable_from_both_sides() { + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "写侧统一测试").expect("init project"); + write_project_history(&root, &[DIRECT_PROJECT_ROW]); + + append_project_message(&root, "模式切换后由通用写入器补写的回复").expect("append legacy row"); + append_local_conversation_message_for_session_idempotent_at( + &root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "带 id 的旧格式回复".to_string(), + agent_id: None, + }, + "direct-codex:turn-0002:assistant", + ) + .expect("append id-bearing legacy row"); + + let history = std::fs::read_to_string(project_conversation_path(&root)).expect("read history"); + assert_eq!(history.lines().count(), 3, "{history}"); + assert!(history.contains("模式切换后由通用写入器补写的回复"), "{history}"); + + // 通用对话链只读自己的 legacy 行,跳过 DirectProject 的行。 + let conversation = read_local_conversation_at(&root, None).expect("read project conversation"); + assert_eq!( + conversation + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec!["模式切换后由通用写入器补写的回复", "带 id 的旧格式回复"] + ); + + // 反向:DirectProject 链把同一份文件里的两种行都读出来,混合文件不构成毒化。 + let direct_items = crate::agent::read_direct_project_history_items_at(&root) + .expect("DirectProject must keep reading the mixed history"); + assert_eq!( + direct_items + .iter() + .map(|item| item["content"][0]["text"].as_str().unwrap_or_default()) + .collect::>(), + vec![ + "再加一个按钮", + "模式切换后由通用写入器补写的回复", + "带 id 的旧格式回复", + ] + ); + + std::fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_history_keeps_accepting_legacy_rows_while_no_direct_project_row_exists() { + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "纯旧格式项目写入测试").expect("init project"); + + append_project_message(&root, "非 direct 模式的项目对话回复").expect("append legacy row"); + let conversation = read_local_conversation_at(&root, None).expect("read legacy conversation"); + assert_eq!(conversation.messages.len(), 1); + assert_eq!(conversation.messages[0].content, "非 direct 模式的项目对话回复"); + + std::fs::remove_dir_all(root).ok(); +}