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(); +}