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 3a434947e..fe3ac8118 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1277,6 +1277,35 @@ pub(crate) fn start_game_creator_agent_background_task_for_session_at( .map(|(result, _run_id)| result) } +#[cfg(test)] +pub(crate) fn start_game_creator_agent_background_task_with_session_lane_hook_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + after_lane_acquired: F, +) -> Result +where + F: FnOnce(), +{ + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || { + after_lane_acquired(); + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, + &agent_id, + session_id, + task, + run_id, + "agent-background-task", + None, + ) + .map(|(result, _run_id)| result) + }) +} + fn start_game_creator_agent_background_task_with_run_id_for_session_at( root: &Path, agent_id: &str, @@ -1318,6 +1347,22 @@ fn start_game_creator_agent_background_task_with_link_at( ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || { + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, &agent_id, session_id, task, run_id, source, task_link, + ) + }) +} + +fn start_game_creator_agent_background_task_with_link_in_session_lane_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + task_link: Option<&AgentRuntimeTaskLink>, +) -> Result<(AgentRuntimeResult, String), String> { let isolated_instance = agent_id .starts_with("child-") .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) @@ -1454,7 +1499,7 @@ fn start_game_creator_agent_background_task_with_link_at( } else { "后台任务从队首开始执行" }; - let state = start_game_creator_agent_runtime_task_for_session_at( + let state = start_game_creator_agent_runtime_task_for_session_in_session_lane_at( root, &agent_id, Some(&next_task.session_id), @@ -1469,7 +1514,7 @@ fn start_game_creator_agent_background_task_with_link_at( let result = read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?; let root = root.to_path_buf(); - let background_agent_id = agent_id.clone(); + let background_agent_id = agent_id.to_string(); let background_task = next_task.task; tauri::async_runtime::spawn(async move { let _runtime_lock = runtime_lock; @@ -18444,6 +18489,30 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 启动", || { + start_game_creator_agent_runtime_task_for_session_in_session_lane_at( + root, + &agent_id, + session_id, + task, + run_id, + source, + current_action, + plan, + ) + }) +} + +fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + current_action: &str, + plan: Vec, +) -> Result { let isolated_instance = agent_id .starts_with("child-") .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d2777a9f0..fe64d2af5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -944,6 +944,25 @@ pub(crate) fn create_game_creator_agent_session( create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) } +#[tauri::command] +pub(crate) fn fork_game_creator_agent_session( + project_path: String, + agent_id: String, + source_session_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + fork_game_creator_agent_session_at( + root, + agent_id.trim(), + source_session_id.trim(), + title.trim(), + ) +} + #[tauri::command] pub(crate) fn set_active_game_creator_agent_session( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e8ee9f4ae..99bfa4cda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -757,6 +757,10 @@ struct AgentConversationSessionRecord { archived_at: Option, message_count: u64, legacy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + forked_from_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + forked_message_count: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -1449,6 +1453,7 @@ fn main() { delete_local_game_memory, list_game_creator_agent_sessions, create_game_creator_agent_session, + fork_game_creator_agent_session, set_active_game_creator_agent_session, archive_game_creator_agent_session, read_local_conversation, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 85695118d..ef185a83a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1492,6 +1492,25 @@ fn project_append_lock_for(path: &Path) -> Result { }) } +pub(crate) fn with_agent_conversation_session_lane_at( + root: &Path, + agent_id: &str, + error_label: &str, + operation: F, +) -> Result +where + F: FnOnce() -> Result, +{ + validate_project_root(root)?; + let agent_id = normalize_conversation_agent_id(agent_id)?; + let lane_path = root + .join(".agent/runtime/session-lanes") + .join(format!("{agent_id}.lane")); + let lane_lock = project_append_lock_for(&lane_path)?; + let _lane_guard = lane_lock.lock(error_label)?; + operation() +} + fn project_append_os_lock_path(path: &Path) -> Result { let agent_root = path .ancestors() @@ -2204,6 +2223,8 @@ fn default_agent_conversation_session_catalog( archived_at: None, message_count: count_conversation_messages(&legacy_path).unwrap_or(0), legacy: true, + forked_from_session_id: None, + forked_message_count: None, }], } } @@ -2285,6 +2306,8 @@ fn read_agent_conversation_session_catalog_unlocked( archived_at: None, message_count: count_conversation_messages(&legacy_path)?, legacy: true, + forked_from_session_id: None, + forked_message_count: None, }, ); seen.insert(legacy_session_id.clone()); @@ -2318,6 +2341,8 @@ fn read_agent_conversation_session_catalog_unlocked( archived_at: None, message_count: count_conversation_messages(&path)?, legacy: false, + forked_from_session_id: None, + forked_message_count: None, }); } } @@ -2331,6 +2356,46 @@ fn read_agent_conversation_session_catalog_unlocked( } } + for session in &mut catalog.sessions { + session.forked_from_session_id = session + .forked_from_session_id + .as_deref() + .map(normalize_agent_conversation_session_id) + .transpose()?; + match ( + session.forked_from_session_id.as_deref(), + session.forked_message_count, + ) { + (Some(source_session_id), Some(message_count)) => { + if source_session_id == session.session_id { + return Err(format!( + "Agent Session 分叉来源不能指向自身:{}", + session.session_id + )); + } + if !seen.contains(source_session_id) { + return Err(format!( + "Agent Session 分叉来源不存在:{} -> {source_session_id}", + session.session_id + )); + } + if message_count > session.message_count { + return Err(format!( + "Agent Session 分叉消息数超过当前消息数:{}", + session.session_id + )); + } + } + (None, None) => {} + _ => { + return Err(format!( + "Agent Session 分叉来源字段不完整:{}", + session.session_id + )); + } + } + } + let active_is_valid = catalog.sessions.iter().any(|session| { session.session_id == catalog.active_session_id && session.archived_at.is_none() }); @@ -2400,32 +2465,36 @@ pub(crate) fn ensure_agent_conversation_session_at( validate_project_root(root)?; let agent_id = normalize_conversation_agent_id(agent_id)?; let session_id = normalize_agent_conversation_session_id(session_id)?; - let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); - let lock = project_append_lock_for(&catalog_path)?; - let _guard = lock.lock("Agent Session 目录")?; - let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; - if let Some(existing) = catalog - .sessions - .iter() - .find(|session| session.session_id == session_id) - { - if existing.archived_at.is_some() { - return Err(format!("Agent Session 已归档:{session_id}")); + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 确保存在", || { + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock.lock("Agent Session 目录")?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; + if let Some(existing) = catalog + .sessions + .iter() + .find(|session| session.session_id == session_id) + { + if existing.archived_at.is_some() { + return Err(format!("Agent Session 已归档:{session_id}")); + } + } else { + let now = unix_timestamp(); + catalog.sessions.push(AgentConversationSessionRecord { + session_id: session_id.clone(), + title: title.trim().chars().take(80).collect::(), + created_at: now, + updated_at: now, + archived_at: None, + message_count: 0, + legacy: false, + forked_from_session_id: None, + forked_message_count: None, + }); } - } else { - let now = unix_timestamp(); - catalog.sessions.push(AgentConversationSessionRecord { - session_id: session_id.clone(), - title: title.trim().chars().take(80).collect::(), - created_at: now, - updated_at: now, - archived_at: None, - message_count: 0, - legacy: false, - }); - } - catalog.active_session_id = session_id; - write_agent_conversation_session_catalog_unlocked(root, &catalog) + catalog.active_session_id = session_id; + write_agent_conversation_session_catalog_unlocked(root, &catalog) + }) } fn agent_conversation_session_list_result( @@ -2447,10 +2516,18 @@ pub(crate) fn list_game_creator_agent_sessions_at( agent_id: &str, ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock.lock("Agent Session 目录")?; read_agent_conversation_session_catalog_unlocked(root, &agent_id) .map(|catalog| agent_conversation_session_list_result(root, catalog)) } +fn agent_runtime_task_is_terminal_for_session_mutation(record: &AgentRuntimeTaskRecord) -> bool { + matches!(record.status.as_str(), "completed" | "failed" | "cancelled") + && record.phase != "needs-reconciliation" +} + fn ensure_agent_session_has_no_live_tasks( root: &Path, agent_id: &str, @@ -2473,9 +2550,14 @@ fn ensure_agent_session_has_no_live_tasks( if line.is_empty() { continue; } - if let Ok(record) = serde_json::from_str::(line) { - latest_by_run.insert(record.run_id.clone(), record); - } + let record = + serde_json::from_str::(line).map_err(|error| { + format!( + "解析 Agent Runtime 任务失败:{}: {error}", + task_path.display() + ) + })?; + latest_by_run.insert(record.run_id.clone(), record); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -2488,14 +2570,11 @@ fn ensure_agent_session_has_no_live_tasks( } let blocked = latest_by_run.values().find(|record| { record.session_id == session_id - && (matches!( - record.status.as_str(), - "pending" | "running" | "waiting-for-confirmation" | "cancelling" - ) || record.phase == "needs-reconciliation") + && !agent_runtime_task_is_terminal_for_session_mutation(record) }); if let Some(record) = blocked { return Err(format!( - "Session {} 仍有未结束任务 {}({} / {}),不能切换或归档", + "Session {} 仍有未结束任务 {}({} / {}),不能切换、归档或分叉", session_id, record.run_id, record.status, record.phase )); } @@ -2533,6 +2612,9 @@ fn ensure_agent_session_has_no_live_tasks( continue; } let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } let mut delegated_latest_by_run = BTreeMap::::new(); let file = File::open(&path) .map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?; @@ -2540,9 +2622,14 @@ fn ensure_agent_session_has_no_live_tasks( let line = line.map_err(|error| { format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()) })?; - if let Ok(record) = serde_json::from_str::(line.trim()) { - delegated_latest_by_run.insert(record.run_id.clone(), record); + let line = line.trim(); + if line.is_empty() { + continue; } + let record = serde_json::from_str::(line).map_err(|error| { + format!("解析 Agent Runtime 任务失败:{}: {error}", path.display()) + })?; + delegated_latest_by_run.insert(record.run_id.clone(), record); } let delegated_child = delegated_latest_by_run.values().find(|record| { record.parent_agent_id.as_deref() == Some(agent_id) @@ -2550,14 +2637,11 @@ fn ensure_agent_session_has_no_live_tasks( .parent_run_id .as_deref() .is_some_and(|parent_run_id| parent_run_ids.contains(&parent_run_id)) - && (matches!( - record.status.as_str(), - "pending" | "running" | "waiting-for-confirmation" | "cancelling" - ) || record.phase == "needs-reconciliation") + && !agent_runtime_task_is_terminal_for_session_mutation(record) }); if let Some(record) = delegated_child { return Err(format!( - "Session {} 的父任务 {} 仍有委派子任务 {}({} / {}),不能切换或归档", + "Session {} 的父任务 {} 仍有委派子任务 {}({} / {}),不能切换、归档或分叉", session_id, record.parent_run_id.as_deref().unwrap_or("-"), record.run_id, @@ -2575,12 +2659,58 @@ pub(crate) fn create_game_creator_agent_session_at( title: &str, ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; - let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); - let lock = project_append_lock_for(&catalog_path)?; - let _guard = lock.lock("Agent Session 目录")?; - let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; - ensure_agent_session_has_no_live_tasks(root, &agent_id, &catalog.active_session_id)?; - let session_id = (0_u32..100) + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 新建", || { + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock.lock("Agent Session 目录")?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; + ensure_agent_session_has_no_live_tasks(root, &agent_id, &catalog.active_session_id)?; + let session_id = new_agent_conversation_session_id(&catalog, &agent_id)?; + let now = unix_timestamp(); + let title = title.trim(); + let title = if title.is_empty() { + "新会话".to_string() + } else { + title.chars().take(80).collect::() + }; + let conversation_path = + conversation_file_path_for_resolved_session(root, &agent_id, &session_id); + if let Some(parent) = conversation_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + } + fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&conversation_path) + .map_err(|error| { + format!( + "创建 Agent Session 对话失败:{}: {error}", + conversation_path.display() + ) + })?; + catalog.sessions.push(AgentConversationSessionRecord { + session_id: session_id.clone(), + title, + created_at: now, + updated_at: now, + archived_at: None, + message_count: 0, + legacy: false, + forked_from_session_id: None, + forked_message_count: None, + }); + catalog.active_session_id = session_id; + write_agent_conversation_session_catalog_unlocked(root, &catalog)?; + Ok(agent_conversation_session_list_result(root, catalog)) + }) +} + +fn new_agent_conversation_session_id( + catalog: &AgentConversationSessionCatalogFile, + agent_id: &str, +) -> Result { + (0_u32..100) .map(|attempt| { format!( "agent-session-{agent_id}-{}-{attempt}", @@ -2596,42 +2726,169 @@ pub(crate) fn create_game_creator_agent_session_at( .iter() .any(|session| session.session_id == *candidate) }) - .ok_or_else(|| "无法生成唯一 Agent Session ID".to_string())?; - let now = unix_timestamp(); - let title = title.trim(); - let title = if title.is_empty() { - "新会话".to_string() - } else { - title.chars().take(80).collect::() - }; - let conversation_path = - conversation_file_path_for_resolved_session(root, &agent_id, &session_id); - if let Some(parent) = conversation_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; - } - fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&conversation_path) - .map_err(|error| { - format!( - "创建 Agent Session 对话失败:{}: {error}", - conversation_path.display() - ) - })?; - catalog.sessions.push(AgentConversationSessionRecord { - session_id: session_id.clone(), + .ok_or_else(|| "无法生成唯一 Agent Session ID".to_string()) +} + +pub(crate) fn fork_game_creator_agent_session_at( + root: &Path, + agent_id: &str, + source_session_id: &str, + title: &str, +) -> Result { + fork_game_creator_agent_session_with_catalog_writer_at( + root, + agent_id, + source_session_id, title, - created_at: now, - updated_at: now, - archived_at: None, - message_count: 0, - legacy: false, - }); - catalog.active_session_id = session_id; - write_agent_conversation_session_catalog_unlocked(root, &catalog)?; - Ok(agent_conversation_session_list_result(root, catalog)) + write_agent_conversation_session_catalog_unlocked, + ) +} + +fn fork_game_creator_agent_session_with_catalog_writer_at( + root: &Path, + agent_id: &str, + source_session_id: &str, + title: &str, + write_catalog: F, +) -> Result +where + F: FnOnce(&Path, &AgentConversationSessionCatalogFile) -> Result<(), String>, +{ + validate_project_root(root)?; + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 分叉", || { + let source_session_id = normalize_agent_conversation_session_id(source_session_id)?; + let source_path = + conversation_file_path_for_resolved_session(root, &agent_id, &source_session_id); + let source_lock = project_append_lock_for(&source_path)?; + let _source_guard = source_lock.lock("Agent Session 分叉源对话")?; + + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let catalog_lock = project_append_lock_for(&catalog_path)?; + let _catalog_guard = catalog_lock.lock("Agent Session 目录")?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; + let source = catalog + .sessions + .iter() + .find(|session| session.session_id == source_session_id) + .cloned() + .ok_or_else(|| format!("Agent Session 不存在:{source_session_id}"))?; + ensure_agent_session_has_no_live_tasks(root, &agent_id, &catalog.active_session_id)?; + if source.session_id != catalog.active_session_id { + ensure_agent_session_has_no_live_tasks(root, &agent_id, &source.session_id)?; + } + + let records = read_persisted_local_conversation_records_unlocked(&source_path)?; + let session_id = new_agent_conversation_session_id(&catalog, &agent_id)?; + let conversation_path = + conversation_file_path_for_resolved_session(root, &agent_id, &session_id); + if let Some(parent) = conversation_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + } + let write_result = (|| -> Result<(), String> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&conversation_path) + .map_err(|error| { + format!( + "创建 Agent Session 分叉对话失败:{}: {error}", + conversation_path.display() + ) + })?; + for record in &records { + serde_json::to_writer(&mut file, record) + .map_err(|error| format!("序列化 Agent Session 分叉消息失败:{error}"))?; + file.write_all(b"\n").map_err(|error| { + format!( + "写入 Agent Session 分叉对话失败:{}: {error}", + conversation_path.display() + ) + })?; + } + file.flush().map_err(|error| { + format!( + "刷新 Agent Session 分叉对话失败:{}: {error}", + conversation_path.display() + ) + }) + })(); + if let Err(error) = write_result { + let _ = fs::remove_file(&conversation_path); + return Err(error); + } + + let now = unix_timestamp(); + let requested_title = title.trim(); + let title = if requested_title.is_empty() { + format!("分支:{}", source.title).chars().take(80).collect() + } else { + requested_title.chars().take(80).collect() + }; + catalog.sessions.push(AgentConversationSessionRecord { + session_id: session_id.clone(), + title, + created_at: now, + updated_at: now, + archived_at: None, + message_count: records.len() as u64, + legacy: false, + forked_from_session_id: Some(source.session_id), + forked_message_count: Some(records.len() as u64), + }); + catalog.active_session_id = session_id; + if let Err(error) = write_catalog(root, &catalog) { + let cleanup = fs::remove_file(&conversation_path); + return Err(match cleanup { + Ok(()) => error, + Err(cleanup_error) => format!( + "{error};清理未登记分叉对话失败:{}: {cleanup_error}", + conversation_path.display() + ), + }); + } + Ok(agent_conversation_session_list_result(root, catalog)) + }) +} + +#[cfg(test)] +pub(crate) fn fork_game_creator_agent_session_with_catalog_failure_at( + root: &Path, + agent_id: &str, + source_session_id: &str, + title: &str, +) -> Result { + fork_game_creator_agent_session_with_catalog_writer_at( + root, + agent_id, + source_session_id, + title, + |_, _| Err("测试注入 Agent Session 目录写入失败".to_string()), + ) +} + +#[cfg(test)] +pub(crate) fn fork_game_creator_agent_session_with_catalog_write_hook_at( + root: &Path, + agent_id: &str, + source_session_id: &str, + title: &str, + before_catalog_write: F, +) -> Result +where + F: FnOnce(), +{ + fork_game_creator_agent_session_with_catalog_writer_at( + root, + agent_id, + source_session_id, + title, + |root, catalog| { + before_catalog_write(); + write_agent_conversation_session_catalog_unlocked(root, catalog) + }, + ) } pub(crate) fn set_active_game_creator_agent_session_at( @@ -2641,24 +2898,26 @@ pub(crate) fn set_active_game_creator_agent_session_at( ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; let session_id = normalize_agent_conversation_session_id(session_id)?; - let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); - let lock = project_append_lock_for(&catalog_path)?; - let _guard = lock.lock("Agent Session 目录")?; - let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; - let session = catalog - .sessions - .iter() - .find(|session| session.session_id == session_id) - .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; - if session.archived_at.is_some() { - return Err(format!( - "Agent Session 已归档,不能设为活动会话:{session_id}" - )); - } - ensure_agent_session_has_no_live_tasks(root, &agent_id, &catalog.active_session_id)?; - catalog.active_session_id = session_id; - write_agent_conversation_session_catalog_unlocked(root, &catalog)?; - Ok(agent_conversation_session_list_result(root, catalog)) + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 切换", || { + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock.lock("Agent Session 目录")?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; + let session = catalog + .sessions + .iter() + .find(|session| session.session_id == session_id) + .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; + if session.archived_at.is_some() { + return Err(format!( + "Agent Session 已归档,不能设为活动会话:{session_id}" + )); + } + ensure_agent_session_has_no_live_tasks(root, &agent_id, &catalog.active_session_id)?; + catalog.active_session_id = session_id; + write_agent_conversation_session_catalog_unlocked(root, &catalog)?; + Ok(agent_conversation_session_list_result(root, catalog)) + }) } pub(crate) fn archive_game_creator_agent_session_at( @@ -2671,30 +2930,32 @@ pub(crate) fn archive_game_creator_agent_session_at( if session_id == legacy_agent_conversation_session_id(&agent_id) { return Err("默认 legacy Session 不能归档".to_string()); } - let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); - let lock = project_append_lock_for(&catalog_path)?; - let _guard = lock.lock("Agent Session 目录")?; - let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; - ensure_agent_session_has_no_live_tasks(root, &agent_id, &session_id)?; - let session = catalog - .sessions - .iter_mut() - .find(|session| session.session_id == session_id) - .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; - if session.archived_at.is_none() { - session.archived_at = Some(unix_timestamp()); - session.updated_at = unix_timestamp(); - } - if catalog.active_session_id == session_id { - catalog.active_session_id = catalog + with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 归档", || { + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock.lock("Agent Session 目录")?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; + ensure_agent_session_has_no_live_tasks(root, &agent_id, &session_id)?; + let session = catalog .sessions - .iter() - .find(|candidate| candidate.archived_at.is_none()) - .map(|candidate| candidate.session_id.clone()) - .ok_or_else(|| "至少需要保留一个未归档 Agent Session".to_string())?; - } - write_agent_conversation_session_catalog_unlocked(root, &catalog)?; - Ok(agent_conversation_session_list_result(root, catalog)) + .iter_mut() + .find(|session| session.session_id == session_id) + .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; + if session.archived_at.is_none() { + session.archived_at = Some(unix_timestamp()); + session.updated_at = unix_timestamp(); + } + if catalog.active_session_id == session_id { + catalog.active_session_id = catalog + .sessions + .iter() + .find(|candidate| candidate.archived_at.is_none()) + .map(|candidate| candidate.session_id.clone()) + .ok_or_else(|| "至少需要保留一个未归档 Agent Session".to_string())?; + } + write_agent_conversation_session_catalog_unlocked(root, &catalog)?; + Ok(agent_conversation_session_list_result(root, catalog)) + }) } fn resolve_agent_conversation_session_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 79f260650..11b1e8256 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1,6 +1,7 @@ use super::*; use serde_json::Value; use sha2::{Digest as _, Sha256}; +use std::collections::BTreeSet; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; @@ -27379,6 +27380,554 @@ fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_conversation_session_fork_copies_snapshot_and_isolates_future_messages() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "会话分叉项目").expect("project init"); + let source_session_id = "agent-session-design-director"; + append_local_conversation_message_for_session_idempotent_at( + &root, + Some("design-director"), + Some(source_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "保留到分叉点的问题".to_string(), + agent_id: None, + }, + "fork-source-message", + ) + .expect("append source message"); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before session fork") + .revision; + + let forked = + fork_game_creator_agent_session_at(&root, "design-director", source_session_id, "") + .expect("fork legacy session"); + let forked_session_id = forked.active_session_id.clone(); + let forked_session = forked + .sessions + .iter() + .find(|session| session.session_id == forked_session_id) + .expect("forked session record"); + assert_eq!(forked_session.title, "分支:默认会话"); + assert_eq!( + forked_session.forked_from_session_id.as_deref(), + Some(source_session_id) + ); + assert_eq!(forked_session.forked_message_count, Some(1)); + assert_eq!(forked_session.message_count, 1); + + let source_path = root.join(".agent/conversations/agents/design-director.jsonl"); + let forked_path = root + .join(".agent/conversations/agents/design-director/sessions") + .join(format!("{forked_session_id}.jsonl")); + let source_raw = fs::read_to_string(&source_path).expect("read source raw conversation"); + let forked_raw = fs::read_to_string(&forked_path).expect("read forked raw conversation"); + assert_eq!(forked_raw, source_raw); + assert!(forked_raw.contains("fork-source-message")); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after session fork") + .revision, + revision_before + ); + + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&forked_session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "只属于分叉会话".to_string(), + agent_id: None, + }, + ) + .expect("append fork-only message"); + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(source_session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "只属于源会话".to_string(), + agent_id: None, + }, + ) + .expect("append source-only message"); + + let source = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(source_session_id), + ) + .expect("read source after fork"); + let forked = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&forked_session_id), + ) + .expect("read fork after source advances"); + assert_eq!(source.messages.len(), 2); + assert_eq!(forked.messages.len(), 2); + assert!(source + .messages + .iter() + .any(|message| message.content == "只属于源会话")); + assert!(!source + .messages + .iter() + .any(|message| message.content == "只属于分叉会话")); + assert!(forked + .messages + .iter() + .any(|message| message.content == "只属于分叉会话")); + assert!(!forked + .messages + .iter() + .any(|message| message.content == "只属于源会话")); + + let second_fork = fork_game_creator_agent_session_at( + &root, + "design-director", + source_session_id, + "第二条方案", + ) + .expect("fork source a second time"); + assert_ne!(second_fork.active_session_id, forked_session_id); + assert_eq!( + second_fork + .sessions + .iter() + .find(|session| session.session_id == second_fork.active_session_id) + .map(|session| session.title.as_str()), + Some("第二条方案") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_fork_accepts_archived_source_and_rejects_live_lane() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "归档会话分叉项目").expect("project init"); + let source = create_game_creator_agent_session_at(&root, "design-director", "旧方案") + .expect("create source session"); + let source_session_id = source.active_session_id; + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&source_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "归档后仍可分叉".to_string(), + agent_id: None, + }, + ) + .expect("append archived source message"); + archive_game_creator_agent_session_at(&root, "design-director", &source_session_id) + .expect("archive source session"); + + let forked = fork_game_creator_agent_session_at( + &root, + "design-director", + &source_session_id, + "归档方案分支", + ) + .expect("fork archived source"); + let forked_session_id = forked.active_session_id.clone(); + assert_eq!( + read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&forked_session_id), + ) + .expect("read archived fork") + .messages[0] + .content, + "归档后仍可分叉" + ); + assert!(forked + .sessions + .iter() + .find(|session| session.session_id == source_session_id) + .and_then(|session| session.archived_at) + .is_some()); + + let state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some(&forked_session_id), + "运行中的分叉任务", + "fork-live-run", + "agent-background-task", + "正在规划", + vec!["完成分叉任务".to_string()], + ) + .expect("start live fork task"); + let error = fork_game_creator_agent_session_at( + &root, + "design-director", + &source_session_id, + "不应创建", + ) + .expect_err("live active lane blocks fork"); + assert!(error.contains("未结束任务")); + finish_game_creator_agent_runtime_turn_at(&root, state, "分叉任务完成") + .expect("finish live fork task"); + + let mut delegated_child = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "fork-delegated-child-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some("fork-live-run".to_string()), + delegation_id: Some("fork-delegation".to_string()), + task: "分叉前仍在运行的委派子任务".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "正在处理委派".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + let sessions_before_child_block = list_game_creator_agent_sessions_at(&root, "design-director") + .expect("list sessions before delegated child block") + .sessions + .len(); + write_agent_runtime_task_record_for_test(&root, &delegated_child); + let child_error = fork_game_creator_agent_session_at( + &root, + "design-director", + &source_session_id, + "不应越过委派子任务", + ) + .expect_err("live delegated child blocks fork"); + assert!(child_error.contains("委派子任务")); + assert_eq!( + list_game_creator_agent_sessions_at(&root, "design-director") + .expect("list sessions after delegated child block") + .sessions + .len(), + sessions_before_child_block + ); + delegated_child.status = "completed".to_string(); + delegated_child.phase = "completed".to_string(); + delegated_child.current_action = "等待下一轮输入".to_string(); + delegated_child.terminal_detail = Some("委派子任务已完成".to_string()); + delegated_child.updated_at = unix_timestamp(); + write_agent_runtime_task_record_for_test(&root, &delegated_child); + + let mut nonactive_source_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: source_session_id.clone(), + run_id: "fork-nonactive-source-run".to_string(), + source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "归档源会话仍有未结束任务".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "正在处理旧方案".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &nonactive_source_task); + let source_error = fork_game_creator_agent_session_at( + &root, + "design-director", + &source_session_id, + "不应越过非活动源任务", + ) + .expect_err("live task in nonactive source blocks fork"); + assert!(source_error.contains("fork-nonactive-source-run")); + nonactive_source_task.status = "completed".to_string(); + nonactive_source_task.phase = "completed".to_string(); + nonactive_source_task.current_action = "等待下一轮输入".to_string(); + nonactive_source_task.terminal_detail = Some("旧方案任务已完成".to_string()); + nonactive_source_task.updated_at = unix_timestamp(); + write_agent_runtime_task_record_for_test(&root, &nonactive_source_task); + + assert!( + fork_game_creator_agent_session_at(&root, "design-director", "../unsafe", "非法来源",) + .expect_err("unsafe source session id") + .contains("session id") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_fork_handles_empty_source_and_cleans_up_catalog_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "空会话分叉项目").expect("project init"); + let source_session_id = "agent-session-design-director"; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before empty session fork") + .revision; + + let forked = + fork_game_creator_agent_session_at(&root, "design-director", source_session_id, "空分支") + .expect("fork empty legacy session"); + let forked_session = forked + .sessions + .iter() + .find(|session| session.session_id == forked.active_session_id) + .expect("empty fork session record"); + assert_eq!(forked_session.message_count, 0); + assert_eq!(forked_session.forked_message_count, Some(0)); + let sessions_dir = root.join(".agent/conversations/agents/design-director/sessions"); + let forked_path = sessions_dir.join(format!("{}.jsonl", forked.active_session_id)); + assert_eq!(fs::read(&forked_path).expect("read empty fork file"), b""); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after empty session fork") + .revision, + revision_before + ); + + let catalog_before = list_game_creator_agent_sessions_at(&root, "design-director") + .expect("list sessions before injected failure"); + let files_before = fs::read_dir(&sessions_dir) + .expect("read session files before injected failure") + .map(|entry| entry.expect("session file before failure").file_name()) + .collect::>(); + let error = fork_game_creator_agent_session_with_catalog_failure_at( + &root, + "design-director", + source_session_id, + "写目录失败", + ) + .expect_err("injected catalog write failure"); + assert!(error.contains("测试注入")); + let files_after = fs::read_dir(&sessions_dir) + .expect("read session files after injected failure") + .map(|entry| entry.expect("session file after failure").file_name()) + .collect::>(); + assert_eq!(files_after, files_before); + assert_eq!( + list_game_creator_agent_sessions_at(&root, "design-director") + .expect("list sessions after injected failure"), + catalog_before + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_fork_is_linearizable_with_default_session_enqueue() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "会话入队竞态项目").expect("project init"); + let runtime_lock = wait_to_acquire_agent_runtime_lock(&root, "design-director"); + let (lane_ready_sender, lane_ready_receiver) = mpsc::channel(); + let (lane_release_sender, lane_release_receiver) = mpsc::channel(); + let runtime_root = root.clone(); + let runtime_thread = thread::spawn(move || { + start_game_creator_agent_background_task_with_session_lane_hook_at( + &runtime_root, + "design-director", + None, + "必须留在分叉前活动会话的输入", + "fork-linearizable-run", + || { + lane_ready_sender.send(()).expect("signal lane acquired"); + lane_release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release runtime enqueue"); + }, + ) + }); + lane_ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("runtime acquired session lane"); + + let (fork_sender, fork_receiver) = mpsc::channel(); + let fork_root = root.clone(); + let fork_thread = thread::spawn(move || { + let result = fork_game_creator_agent_session_at( + &fork_root, + "design-director", + "agent-session-design-director", + "竞态分支", + ); + fork_sender.send(result).expect("send fork result"); + }); + assert!(fork_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + lane_release_sender + .send(()) + .expect("continue runtime enqueue"); + let queued = runtime_thread + .join() + .expect("join runtime enqueue") + .expect("queue runtime in original active session"); + assert_eq!( + queued.task_queue.latest_run_id.as_deref(), + Some("fork-linearizable-run") + ); + let fork_error = fork_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("receive blocked fork") + .expect_err("queued runtime must block fork"); + fork_thread.join().expect("join fork thread"); + assert!(fork_error.contains("fork-linearizable-run")); + assert_eq!( + list_game_creator_agent_sessions_at(&root, "design-director") + .expect("list sessions after blocked fork") + .active_session_id, + "agent-session-design-director" + ); + assert_eq!( + read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some("agent-session-design-director"), + ) + .expect("read original session after enqueue") + .messages[0] + .content, + "必须留在分叉前活动会话的输入" + ); + drop(runtime_lock); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_fork_fails_closed_on_invalid_task_journals() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "损坏任务日志项目").expect("project init"); + let task_dir = root.join(".agent/runtime/tasks"); + fs::create_dir_all(&task_dir).expect("create task directory"); + let parent_path = task_dir.join("design-director.jsonl"); + fs::write(&parent_path, b"{truncated\n").expect("write corrupt parent task journal"); + let parent_error = fork_game_creator_agent_session_at( + &root, + "design-director", + "agent-session-design-director", + "损坏父日志分支", + ) + .expect_err("corrupt parent task journal blocks fork"); + assert!(parent_error.contains("解析 Agent Runtime 任务失败")); + + fs::remove_file(&parent_path).expect("remove corrupt parent journal"); + let mut parent_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "fork-unknown-status-run".to_string(), + source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "未知状态必须失败关闭".to_string(), + status: "needs-reconciliation".to_string(), + phase: "planning".to_string(), + current_action: "等待人工对账".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &parent_task); + let unknown_error = fork_game_creator_agent_session_at( + &root, + "design-director", + "agent-session-design-director", + "未知状态分支", + ) + .expect_err("unknown task status blocks fork"); + assert!(unknown_error.contains("fork-unknown-status-run")); + parent_task.status = "completed".to_string(); + parent_task.phase = "completed".to_string(); + parent_task.current_action = "等待下一轮输入".to_string(); + parent_task.terminal_detail = Some("已完成人工对账".to_string()); + parent_task.updated_at = unix_timestamp(); + write_agent_runtime_task_record_for_test(&root, &parent_task); + + let child_path = task_dir.join("art-director.jsonl"); + fs::write(&child_path, b"{truncated-child\n").expect("write corrupt child task journal"); + let child_error = fork_game_creator_agent_session_at( + &root, + "design-director", + "agent-session-design-director", + "损坏子日志分支", + ) + .expect_err("corrupt child task journal blocks fork"); + assert!(child_error.contains("解析 Agent Runtime 任务失败")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_list_hides_uncommitted_fork_file() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "分叉目录原子可见性项目").expect("project init"); + let (hook_ready_sender, hook_ready_receiver) = mpsc::channel(); + let (hook_release_sender, hook_release_receiver) = mpsc::channel(); + let fork_root = root.clone(); + let fork_thread = thread::spawn(move || { + fork_game_creator_agent_session_with_catalog_write_hook_at( + &fork_root, + "design-director", + "agent-session-design-director", + "原子可见分支", + || { + hook_ready_sender + .send(()) + .expect("signal fork file written"); + hook_release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release catalog write"); + }, + ) + }); + hook_ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("fork reached catalog write hook"); + + let (list_sender, list_receiver) = mpsc::channel(); + let list_root = root.clone(); + let list_thread = thread::spawn(move || { + list_sender + .send(list_game_creator_agent_sessions_at( + &list_root, + "design-director", + )) + .expect("send session list result"); + }); + assert!(list_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + hook_release_sender.send(()).expect("commit fork catalog"); + let forked = fork_thread + .join() + .expect("join fork thread") + .expect("commit fork"); + let listed = list_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("receive committed session list") + .expect("list committed sessions"); + list_thread.join().expect("join list thread"); + assert_eq!(listed.active_session_id, forked.active_session_id); + assert!(listed + .sessions + .iter() + .any(|session| session.session_id == forked.active_session_id)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cebcfa54e..8fde0893a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -22,6 +22,7 @@ import { FileText, FolderKanban, Gamepad2, + GitFork, Home, Image, LogOut, @@ -597,6 +598,8 @@ interface AgentConversationSessionRecord { archivedAt: number | null; messageCount: number; legacy: boolean; + forkedFromSessionId?: string | null; + forkedMessageCount?: number | null; } interface AgentConversationSessionListResult { @@ -630,6 +633,30 @@ function createAgentChatRunId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function formatAgentConversationSessionMeta( + session: AgentConversationSessionRecord, + sessions: AgentConversationSessionRecord[], + activeSessionId: string | null, +) { + const details = [String(session.messageCount)]; + if (session.sessionId === activeSessionId) { + details.push('活动'); + } + if (session.forkedFromSessionId) { + const source = sessions.find( + (candidate) => candidate.sessionId === session.forkedFromSessionId, + ); + const sourceLabel = source?.title ?? session.forkedFromSessionId; + details.push( + session.forkedMessageCount === null || + session.forkedMessageCount === undefined + ? `分支自 ${sourceLabel}` + : `分支自 ${sourceLabel}(${session.forkedMessageCount} 条)`, + ); + } + return details.join(' · '); +} + function agentRuntimePlanStepsFromPlan(plan: string[]): AgentRuntimePlanStep[] { return plan .filter((item) => item.trim().length > 0) @@ -3333,7 +3360,9 @@ export function WorkspaceLauncher({ function handleAgentChatMessagesScroll(event: UIEvent) { const messageList = event.currentTarget; const distanceFromBottom = - messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight; + messageList.scrollHeight - + messageList.scrollTop - + messageList.clientHeight; agentChatShouldFollowLatestRef.current = distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; } @@ -4653,6 +4682,51 @@ export function WorkspaceLauncher({ } } + async function handleAgentChatForkSession() { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + const sourceSession = selectedLauncherAgentChatSession(); + if ( + !projectPathForChat || + !agent || + !sourceSession || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + setAgentChatBusy(true); + setAgentChatStatus('正在分叉会话'); + try { + const result = await invoke( + 'fork_game_creator_agent_session', + { + projectPath: projectPathForChat, + agentId: agent.id, + sourceSessionId: sourceSession.sessionId, + title: '', + }, + ); + await loadAgentChatConversation( + agent.id, + projectPathForChat, + result.activeSessionId, + result, + ); + setAgentChatStatus(`已从“${sourceSession.title}”分叉新会话`); + } catch (error) { + setAgentChatStatus( + error instanceof Error ? error.message : String(error), + ); + setAgentChatBusy(false); + } + } + async function handleAgentChatArchiveSession() { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); @@ -6234,6 +6308,21 @@ export function WorkspaceLauncher({ >