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 237790f5c..64b0948c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -164,8 +164,23 @@ pub(crate) async fn chat_with_game_creator_role_agent_at( agent_id: &str, prompt: &str, ) -> Result { - let (llm, config_path, request) = - build_game_creator_role_agent_chat_request(root, agent_id, prompt)?; + chat_with_game_creator_role_agent_for_session_at(root, agent_id, None, prompt).await +} + +pub(crate) async fn chat_with_game_creator_role_agent_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + prompt: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; + let (llm, config_path, request) = build_game_creator_role_agent_chat_request_for_session( + root, + &agent_id, + Some(&session_id), + prompt, + )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; let response = request_game_creator_llm_text(&client, &llm, request) .await @@ -184,7 +199,26 @@ pub(crate) async fn chat_with_game_creator_role_agent_runtime_at( prompt: &str, run_id: &str, ) -> Result<(GameCreatorChatAgentReply, AgentRuntimeState), String> { - let mut runtime = start_game_creator_agent_runtime_turn_at(root, agent_id, prompt, run_id)?; + chat_with_game_creator_role_agent_runtime_for_session_at(root, agent_id, None, prompt, run_id) + .await +} + +pub(crate) async fn chat_with_game_creator_role_agent_runtime_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + prompt: &str, + run_id: &str, +) -> Result<(GameCreatorChatAgentReply, AgentRuntimeState), String> { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; + let mut runtime = start_game_creator_agent_runtime_turn_for_session_at( + root, + &agent_id, + Some(&session_id), + prompt, + run_id, + )?; runtime = advance_game_creator_agent_runtime_turn_at( root, runtime, @@ -192,7 +226,14 @@ pub(crate) async fn chat_with_game_creator_role_agent_runtime_at( "请求 Agent LLM", "已读取项目上下文,正在让 Agent 独立推理。", )?; - match chat_with_game_creator_role_agent_at(root, agent_id, prompt).await { + match chat_with_game_creator_role_agent_for_session_at( + root, + &agent_id, + Some(&session_id), + prompt, + ) + .await + { Ok(reply) => { let runtime = finish_game_creator_agent_runtime_turn_at(root, runtime, &reply.reply_text)?; @@ -209,13 +250,33 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream_at( root: &Path, agent_id: &str, prompt: &str, + on_delta: F, +) -> Result +where + F: FnMut(&platform_llm::LlmStreamDelta), +{ + chat_with_game_creator_role_agent_stream_for_session_at(root, agent_id, None, prompt, on_delta) + .await +} + +pub(crate) async fn chat_with_game_creator_role_agent_stream_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + prompt: &str, mut on_delta: F, ) -> Result where F: FnMut(&platform_llm::LlmStreamDelta), { - let (llm, config_path, request) = - build_game_creator_role_agent_chat_request(root, agent_id, prompt)?; + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; + let (llm, config_path, request) = build_game_creator_role_agent_chat_request_for_session( + root, + &agent_id, + Some(&session_id), + prompt, + )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; let response = client .stream_run(request, |delta| on_delta(delta)) @@ -232,6 +293,24 @@ where pub(crate) fn read_game_creator_agent_runtime_at( root: &Path, agent_id: &str, +) -> Result { + read_game_creator_agent_runtime_with_session_filter_at(root, agent_id, None) +} + +pub(crate) fn read_game_creator_agent_runtime_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, false)?; + read_game_creator_agent_runtime_with_session_filter_at(root, &agent_id, Some(&session_id)) +} + +fn read_game_creator_agent_runtime_with_session_filter_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; @@ -256,7 +335,16 @@ pub(crate) fn read_game_creator_agent_runtime_at( } }; normalize_game_creator_agent_runtime_state(&mut state, &agent_id); - if !state.run_id.trim().is_empty() + let state_matches_session = session_id + .map(|session_id| state.session_id == session_id) + .unwrap_or(true); + if !state_matches_session { + let mut idle_state = default_game_creator_agent_runtime_state(&agent_id, ""); + idle_state.session_id = session_id.unwrap_or_default().to_string(); + state = idle_state; + } + if state_matches_session + && !state.run_id.trim().is_empty() && !matches!(state.phase.as_str(), "completed" | "cancelled" | "failed") { let pending_path = @@ -288,7 +376,8 @@ pub(crate) fn read_game_creator_agent_runtime_at( Some("待确认动作执行记录缺失;旧版本任务只能取消或重试,不能直接批准".to_string()); } } - if game_creator_agent_runtime_cancel_requested(root, &state) + if state_matches_session + && game_creator_agent_runtime_cancel_requested(root, &state) && matches!( state.status.as_str(), "running" | "waiting-for-confirmation" @@ -302,8 +391,10 @@ pub(crate) fn read_game_creator_agent_runtime_at( state.next_step = "取消完成后可重试该任务或提交新任务".to_string(); } let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); - let recent_events = read_recent_game_creator_agent_runtime_events(&event_path)?; - let task_snapshot = read_game_creator_agent_runtime_task_snapshot(&task_path)?; + let recent_events = + read_recent_game_creator_agent_runtime_events_for_session(&event_path, session_id)?; + let task_snapshot = + read_game_creator_agent_runtime_task_snapshot_for_session(&task_path, session_id)?; state.task_queue = task_snapshot.task_queue.clone(); Ok(AgentRuntimeResult { state, @@ -356,9 +447,10 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( } else { task.source.as_str() }; - let state = match start_game_creator_agent_runtime_task_at( + let state = match start_game_creator_agent_runtime_task_for_session_at( root, &agent_id, + Some(&task.session_id), &task.task, &task.run_id, source, @@ -367,7 +459,9 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( ) { Ok(state) => state, Err(error) => { - let fallback = default_game_creator_agent_runtime_state(&agent_id, &task.run_id); + let mut fallback = + default_game_creator_agent_runtime_state(&agent_id, &task.run_id); + fallback.session_id = task.session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(root, fallback, &error); continue; } @@ -429,6 +523,19 @@ fn resume_game_creator_agent_pending_tool_action_at( } let pending = read_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; + let session_mismatch = pending.session_id != runtime.session_id + || read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? + .is_some_and(|task| task.session_id != pending.session_id); + if session_mismatch { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "Agent Runtime 恢复时发现任务、状态与待确认动作的 Session 不一致", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { @@ -575,8 +682,20 @@ pub(crate) fn start_game_creator_agent_background_task_at( task: &str, run_id: &str, ) -> Result { - start_game_creator_agent_background_task_with_run_id_at(root, agent_id, task, run_id) - .map(|(result, _run_id)| result) + start_game_creator_agent_background_task_for_session_at(root, agent_id, None, task, run_id) +} + +pub(crate) fn start_game_creator_agent_background_task_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, +) -> Result { + start_game_creator_agent_background_task_with_run_id_for_session_at( + root, agent_id, session_id, task, run_id, + ) + .map(|(result, _run_id)| result) } fn start_game_creator_agent_background_task_with_run_id_at( @@ -584,10 +703,23 @@ fn start_game_creator_agent_background_task_with_run_id_at( agent_id: &str, task: &str, run_id: &str, +) -> Result<(AgentRuntimeResult, String), String> { + start_game_creator_agent_background_task_with_run_id_for_session_at( + root, agent_id, None, task, run_id, + ) +} + +fn start_game_creator_agent_background_task_with_run_id_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, ) -> Result<(AgentRuntimeResult, String), String> { start_game_creator_agent_background_task_with_source_at( root, agent_id, + session_id, task, run_id, "agent-background-task", @@ -597,12 +729,14 @@ fn start_game_creator_agent_background_task_with_run_id_at( fn start_game_creator_agent_background_task_with_source_at( root: &Path, agent_id: &str, + session_id: Option<&str>, task: &str, run_id: &str, source: &str, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; let task = task.trim(); if task.is_empty() { return Err("Agent 后台任务不能为空".to_string()); @@ -613,11 +747,18 @@ fn start_game_creator_agent_background_task_with_source_at( source.trim() }; let run_id = unique_game_creator_agent_runtime_run_id(root, &agent_id, run_id)?; - let pending_task = - append_game_creator_agent_runtime_pending_task(root, &agent_id, task, &run_id, source)?; - append_local_conversation_message_at( + let pending_task = append_game_creator_agent_runtime_pending_task( + root, + &agent_id, + &session_id, + task, + &run_id, + source, + )?; + append_local_conversation_message_for_session_at( root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "user".to_string(), content: task.to_string(), @@ -640,7 +781,8 @@ fn start_game_creator_agent_background_task_with_source_at( )?; let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? else { - let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + let result = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?; emit_game_creator_agent_runtime_update(root, &agent_id); return Ok((result, run_id)); }; @@ -651,21 +793,24 @@ fn start_game_creator_agent_background_task_with_source_at( ) || game_creator_agent_runtime_has_reconciliation_barrier(root, &agent_id)? { emit_game_creator_agent_runtime_update(root, &agent_id); - return Ok((result, run_id)); + return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) + .map(|result| (result, run_id)); } let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(root, &agent_id)? else { emit_game_creator_agent_runtime_update(root, &agent_id); - return Ok((result, run_id)); + return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) + .map(|result| (result, run_id)); }; let start_observation = if next_task.run_id == run_id { "后台任务已投递" } else { "后台任务从队首开始执行" }; - let state = start_game_creator_agent_runtime_task_at( + let state = start_game_creator_agent_runtime_task_for_session_at( root, &agent_id, + Some(&next_task.session_id), &next_task.task, &next_task.run_id, &next_task.source, @@ -674,7 +819,8 @@ fn start_game_creator_agent_background_task_with_source_at( )?; append_game_creator_agent_background_task_started_record(root, &state)?; - let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + 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_task = next_task.task; @@ -720,6 +866,7 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at( match start_game_creator_agent_background_task_with_source_at( root, &task.id, + None, &task_text, &run_id, "agent-ready-task-scheduler", @@ -731,6 +878,7 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at( "recordType": "agent.runtime.ready_task.scheduled", "agentId": result.state.agent_id, "taskId": task.id.clone(), + "sessionId": result.state.session_id, "runId": actual_run_id, "source": result.state.source, "title": task.title.clone(), @@ -939,6 +1087,7 @@ fn append_game_creator_agent_runtime_queued_cancellation( append_game_creator_agent_runtime_cancelled_task_record(root, task, summary)?; let mut event_state = default_game_creator_agent_runtime_state(agent_id, &cancelled_task.run_id); + event_state.session_id = cancelled_task.session_id.clone(); event_state.source = cancelled_task.source.clone(); event_state.current_task = cancelled_task.task.clone(); event_state.current_goal = cancelled_task.task.clone(); @@ -1011,8 +1160,13 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( } else { next_run_id.trim().to_string() }; - let result = - start_game_creator_agent_background_task_at(root, &agent_id, &task.task, &retry_run_id)?; + let result = start_game_creator_agent_background_task_for_session_at( + root, + &agent_id, + Some(&task.session_id), + &task.task, + &retry_run_id, + )?; append_agent_db_record( root, serde_json::json!({ @@ -1222,8 +1376,14 @@ fn resolve_game_creator_agent_runtime_pending_tool_action( "Agent Runtime 当前状态不是该待确认 run:{target_run_id}" )); } + if runtime.session_id != task.session_id { + return Err("Agent Runtime 状态与任务记录的 Session 不一致".to_string()); + } let pending = read_game_creator_agent_runtime_pending_tool_action(root, &agent_id, &target_run_id)?; + if pending.session_id != task.session_id || pending.session_id != runtime.session_id { + return Err("Agent Runtime 待确认动作与任务记录的 Session 不一致".to_string()); + } validate_agent_runtime_pending_tool_action_content(root, &pending.action, &pending.task)?; if pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { return Err("Agent Runtime 待确认动作已处理,请刷新状态".to_string()); @@ -1660,9 +1820,10 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: else { break; }; - let state = match start_game_creator_agent_runtime_task_at( + let state = match start_game_creator_agent_runtime_task_for_session_at( &root, &agent_id, + Some(&next_task.session_id), &next_task.task, &next_task.run_id, next_task.source.as_str(), @@ -1671,8 +1832,9 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: ) { Ok(state) => state, Err(error) => { - let fallback = + let mut fallback = default_game_creator_agent_runtime_state(&agent_id, &next_task.run_id); + fallback.session_id = next_task.session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); break; } @@ -1744,6 +1906,7 @@ async fn run_game_creator_agent_background_task_with_context( initial_observations: Vec, start_loop_index: usize, ) -> AgentBackgroundTaskOutcome { + let session_id = state.session_id.clone(); let mut runtime = state; let mut plan = initial_plan; let mut observations = initial_observations; @@ -1770,7 +1933,8 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -1779,6 +1943,7 @@ async fn run_game_creator_agent_background_task_with_context( plan = match request_game_creator_agent_background_tool_plan_at( &root, &agent_id, + &runtime.session_id, &task, &observations, loop_index + 1, @@ -1792,9 +1957,10 @@ async fn run_game_creator_agent_background_task_with_context( } let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: format!("后台任务失败:{error}"), @@ -1892,7 +2058,8 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -1954,9 +2121,10 @@ async fn run_game_creator_agent_background_task_with_context( write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) { let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: format!("后台任务失败:{error}"), @@ -2069,9 +2237,10 @@ async fn run_game_creator_agent_background_task_with_context( write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) { let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: format!("后台任务失败:{error}"), @@ -2219,7 +2388,8 @@ async fn run_game_creator_agent_background_task_with_context( ) { Ok(runtime) => runtime, Err(error) => { - let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let mut fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + fallback.session_id = session_id.clone(); let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -2230,6 +2400,7 @@ async fn run_game_creator_agent_background_task_with_context( let final_reply_result = request_game_creator_agent_background_final_reply_at( &root, &agent_id, + &runtime.session_id, &task, &plan, &observations, @@ -2244,9 +2415,10 @@ async fn run_game_creator_agent_background_task_with_context( Err(error) => { let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: format!("后台任务失败:{error}"), @@ -2291,9 +2463,10 @@ async fn run_game_creator_agent_background_task_with_context( } Err(error) => { let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: format!("后台任务失败:{error}"), @@ -2317,9 +2490,10 @@ async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::Finished; } } - let _ = append_local_conversation_message_at( + let _ = append_local_conversation_message_for_session_at( &root, Some(&agent_id), + Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), content: final_reply.clone(), @@ -2906,6 +3080,7 @@ fn complete_agent_runtime_remaining_plan_steps(runtime: &mut AgentRuntimeState, async fn request_game_creator_agent_background_tool_plan_at( root: &Path, agent_id: &str, + session_id: &str, task: &str, observations: &[AgentRuntimeToolObservation], loop_index: usize, @@ -2913,6 +3088,7 @@ async fn request_game_creator_agent_background_tool_plan_at( let (llm, config_path, request) = build_game_creator_agent_background_tool_plan_request( root, agent_id, + session_id, task, observations, loop_index, @@ -2927,6 +3103,7 @@ async fn request_game_creator_agent_background_tool_plan_at( async fn request_game_creator_agent_background_final_reply_at( root: &Path, agent_id: &str, + session_id: &str, task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], @@ -2934,6 +3111,7 @@ async fn request_game_creator_agent_background_final_reply_at( let (llm, config_path, request) = build_game_creator_agent_background_final_reply_request( root, agent_id, + session_id, task, plan, observations, @@ -2952,11 +3130,13 @@ async fn request_game_creator_agent_background_final_reply_at( fn build_game_creator_agent_background_tool_plan_request( root: &Path, agent_id: &str, + session_id: &str, task: &str, observations: &[AgentRuntimeToolObservation], loop_index: usize, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { - let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; let observations_json = if observations.is_empty() { "[]".to_string() } else { @@ -2981,11 +3161,13 @@ fn build_game_creator_agent_background_tool_plan_request( fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, + session_id: &str, task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { - let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; let observations_json = serde_json::to_string_pretty(observations) .map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?; let plan_json = serde_json::to_string_pretty(plan) @@ -3064,7 +3246,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( match tool { "memory.read" => observe_agent_runtime_memory(root, agent_id, &action.input), "memory.write" => observe_agent_runtime_memory_write(root, agent_id, &action.input), - "conversation.read" => observe_agent_runtime_conversation(root, agent_id), + "conversation.read" => observe_agent_runtime_conversation(root, agent_id, run_id), "asset.list" => observe_agent_runtime_assets(root), "project.index" => observe_agent_runtime_project_index(root), "project.checkpoint" => observe_agent_runtime_project_checkpoint(root), @@ -3088,7 +3270,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( "agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input), "agent.delegate" => observe_agent_runtime_agent_delegate(root, agent_id, &action.input), "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), - "agent.run_status" => observe_agent_runtime_run_status(root, agent_id, &action.input), + "agent.run_status" => { + observe_agent_runtime_run_status(root, agent_id, run_id, &action.input) + } _ => AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), @@ -3864,12 +4048,49 @@ fn observe_agent_runtime_memory_write( } } -fn observe_agent_runtime_conversation(root: &Path, agent_id: &str) -> AgentRuntimeToolObservation { - observation_from_text_result( - "conversation.read", - render_local_conversation_prompt_context(root, Some(agent_id)), - "已读取本 Agent 最近对话", - ) +fn resolve_game_creator_agent_runtime_session_id_for_run_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id); + if let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)? + { + return resolve_agent_conversation_session_id_at( + root, + &agent_id, + Some(&task.session_id), + false, + ); + } + let runtime = read_game_creator_agent_runtime_at(root, &agent_id)?; + if runtime.state.run_id == run_id && !runtime.state.session_id.trim().is_empty() { + return resolve_agent_conversation_session_id_at( + root, + &agent_id, + Some(&runtime.state.session_id), + false, + ); + } + resolve_agent_conversation_session_id_at(root, &agent_id, None, false) +} + +fn observe_agent_runtime_conversation( + root: &Path, + agent_id: &str, + run_id: &str, +) -> AgentRuntimeToolObservation { + let result = resolve_game_creator_agent_runtime_session_id_for_run_at(root, agent_id, run_id) + .and_then(|session_id| { + render_local_conversation_prompt_context_for_session( + root, + Some(agent_id), + Some(&session_id), + ) + }); + observation_from_text_result("conversation.read", result, "已读取本 Agent 最近对话") } fn observe_agent_runtime_assets(root: &Path) -> AgentRuntimeToolObservation { @@ -4804,6 +5025,7 @@ fn observe_agent_runtime_agent_message( "recordType": "agent.runtime.agent.message", "agentId": agent_id, "targetAgentId": target_agent_id.clone(), + "targetSessionId": conversation.session_id, "path": conversation.path, }), ) @@ -4883,6 +5105,7 @@ fn observe_agent_runtime_agent_delegate( "recordType": "agent.runtime.agent.delegate", "agentId": agent_id, "targetAgentId": target_agent_id, + "targetSessionId": target_state.session_id.clone(), "runId": delegated_run_id, "status": status, "task": sanitize_agent_runtime_text(&task, 180), @@ -4960,6 +5183,7 @@ fn observe_agent_runtime_schedule_ready_tasks( fn observe_agent_runtime_run_status( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let scope = agent_runtime_tool_input_text(input, &["scope", "mode"]); @@ -4987,8 +5211,23 @@ fn observe_agent_runtime_run_status( } else { target_agent_id }; - read_game_creator_agent_runtime_at(root, &target_agent_id) - .map(|runtime| format_agent_runtime_status_observation(&runtime)) + let normalized_target = normalize_game_creator_runtime_agent_id(&target_agent_id); + normalized_target.and_then(|normalized_target| { + if normalized_target == normalize_game_creator_runtime_agent_id(agent_id)? { + let session_id = resolve_game_creator_agent_runtime_session_id_for_run_at( + root, agent_id, run_id, + )?; + read_game_creator_agent_runtime_for_session_at( + root, + &normalized_target, + Some(&session_id), + ) + .map(|runtime| format_agent_runtime_status_observation(&runtime)) + } else { + read_game_creator_agent_runtime_at(root, &normalized_target) + .map(|runtime| format_agent_runtime_status_observation(&runtime)) + } + }) }; match result { Ok(detail) => { @@ -5278,9 +5517,20 @@ pub(crate) fn start_game_creator_agent_runtime_turn_at( prompt: &str, run_id: &str, ) -> Result { - start_game_creator_agent_runtime_task_at( + start_game_creator_agent_runtime_turn_for_session_at(root, agent_id, None, prompt, run_id) +} + +pub(crate) fn start_game_creator_agent_runtime_turn_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + prompt: &str, + run_id: &str, +) -> Result { + start_game_creator_agent_runtime_task_for_session_at( root, agent_id, + session_id, prompt, run_id, "agent-chat", @@ -5301,19 +5551,44 @@ pub(crate) fn start_game_creator_agent_runtime_task_at( source: &str, current_action: &str, plan: Vec, +) -> Result { + start_game_creator_agent_runtime_task_for_session_at( + root, + agent_id, + None, + task, + run_id, + source, + current_action, + plan, + ) +} + +pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + current_action: &str, + plan: Vec, ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; let run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id); let task = task.trim(); if task.is_empty() { return Err("Agent Runtime 任务不能为空".to_string()); } let runtime_task = sanitize_agent_runtime_text(task, 180); - let previous_state = read_game_creator_agent_runtime_at(root, &agent_id) - .ok() - .map(|result| result.state); + let previous_state = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) + .ok() + .map(|result| result.state); let mut state = default_game_creator_agent_runtime_state(&agent_id, &run_id); + state.session_id = session_id; state.source = source.trim().to_string(); state.status = "running".to_string(); state.phase = "planning".to_string(); @@ -6278,6 +6553,7 @@ fn stop_game_creator_agent_runtime_if_cancel_requested( fn append_game_creator_agent_runtime_pending_task( root: &Path, agent_id: &str, + session_id: &str, task: &str, run_id: &str, source: &str, @@ -6286,7 +6562,7 @@ fn append_game_creator_agent_runtime_pending_task( schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: agent_id.to_string(), task_id: agent_id.to_string(), - session_id: format!("agent-session-{agent_id}"), + session_id: session_id.to_string(), run_id: run_id.to_string(), source: source.trim().to_string(), task: sanitize_agent_runtime_text(task, 180), @@ -6343,6 +6619,13 @@ fn game_creator_agent_runtime_task_status(state: &AgentRuntimeState) -> String { fn read_recent_game_creator_agent_runtime_events( path: &Path, +) -> Result, String> { + read_recent_game_creator_agent_runtime_events_for_session(path, None) +} + +fn read_recent_game_creator_agent_runtime_events_for_session( + path: &Path, + session_id: Option<&str>, ) -> Result, String> { let mut events = Vec::new(); match File::open(path) { @@ -6358,6 +6641,9 @@ fn read_recent_game_creator_agent_runtime_events( match serde_json::from_str::(line) { Ok(mut event) => { normalize_game_creator_agent_runtime_event(&mut event); + if session_id.is_some_and(|session_id| event.session_id != session_id) { + continue; + } events.push(event); } Err(_) => continue, @@ -6386,9 +6672,19 @@ struct AgentRuntimeTaskSnapshot { fn read_game_creator_agent_runtime_task_snapshot( path: &Path, +) -> Result { + read_game_creator_agent_runtime_task_snapshot_for_session(path, None) +} + +fn read_game_creator_agent_runtime_task_snapshot_for_session( + path: &Path, + session_id: Option<&str>, ) -> Result { let records = read_all_game_creator_agent_runtime_tasks(path)?; - let latest = latest_game_creator_agent_runtime_tasks(records); + let latest = latest_game_creator_agent_runtime_tasks(records) + .into_iter() + .filter(|record| session_id.map_or(true, |session_id| record.session_id == session_id)) + .collect::>(); let task_queue = summarize_game_creator_agent_runtime_task_queue(&latest); let mut recent = latest; if recent.len() > AGENT_RUNTIME_RECENT_TASK_LIMIT { @@ -6588,7 +6884,21 @@ fn render_agent_runtime_tool_names(values: &[String], limit: usize) -> String { } fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result { - let runtime = match read_game_creator_agent_runtime_at(root, agent_id) { + render_agent_runtime_prompt_context_for_session(root, agent_id, None) +} + +fn render_agent_runtime_prompt_context_for_session( + root: &Path, + agent_id: &str, + session_id: Option<&str>, +) -> Result { + let runtime_result = match session_id { + Some(session_id) => { + read_game_creator_agent_runtime_for_session_at(root, agent_id, Some(session_id)) + } + None => read_game_creator_agent_runtime_at(root, agent_id), + }; + let runtime = match runtime_result { Ok(runtime) => runtime, Err(error) => { return Ok(format!( @@ -6876,12 +7186,22 @@ pub(crate) fn build_game_creator_role_agent_chat_request( root: &Path, agent_id: &str, prompt: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + build_game_creator_role_agent_chat_request_for_session(root, agent_id, None, prompt) +} + +pub(crate) fn build_game_creator_role_agent_chat_request_for_session( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + prompt: &str, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { let prompt = prompt.trim(); if prompt.is_empty() { return Err("聊天内容不能为空".to_string()); } - let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, session_id)?; let user_prompt = if context.trim().is_empty() { format!("用户这轮输入:\n{prompt}") } else { @@ -6899,9 +7219,18 @@ pub(crate) fn build_game_creator_role_agent_chat_request( fn build_game_creator_role_agent_context( root: &Path, agent_id: &str, +) -> Result<(GameCreatorLlmConfig, String, String), String> { + build_game_creator_role_agent_context_for_session(root, agent_id, None) +} + +fn build_game_creator_role_agent_context_for_session( + root: &Path, + agent_id: &str, + session_id: Option<&str>, ) -> Result<(GameCreatorLlmConfig, String, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; + let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, false)?; let (group_definition, role_definition) = game_creator_agent_role_definition(&agent_id) .ok_or_else(|| format!("未知 Agent:{agent_id}"))?; @@ -6909,9 +7238,14 @@ fn build_game_creator_role_agent_context( let long_memory = read_optional_text(&root.join("memory/project.md"))?; let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; let agent_memory = read_local_agent_memory_at(root, &agent_id)?.content; - let runtime_context = render_agent_runtime_prompt_context(root, &agent_id)?; + let runtime_context = + render_agent_runtime_prompt_context_for_session(root, &agent_id, Some(&session_id))?; let asset_context = render_local_asset_prompt_context(root)?; - let conversation_context = render_local_conversation_prompt_context(root, Some(&agent_id))?; + let conversation_context = render_local_conversation_prompt_context_for_session( + root, + Some(&agent_id), + Some(&session_id), + )?; let identity = format!( "你当前是 {} / {},taskId={},角色代号={}。请只以这个专业 Agent 的身份回应。", group_definition.label, role_definition.role, role_definition.task_id, role_definition.id @@ -10841,6 +11175,14 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result, +) -> Result { + render_local_conversation_prompt_context_for_session(root, agent_id, None) +} + +pub(crate) fn render_local_conversation_prompt_context_for_session( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, ) -> Result { #[derive(Debug)] struct ConversationPromptEntry { @@ -10876,33 +11218,45 @@ pub(crate) fn render_local_conversation_prompt_context( let mut entries = Vec::new(); push_conversation_entries( &mut entries, - read_local_conversation_at(root, None)?, + read_local_conversation_for_session_at(root, None, None)?, "project", ); if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) { if agent_id == "*" { + if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("读取全部 Agent 对话时不能指定单个 sessionId".to_string()); + } let agents_dir = root.join(".agent/conversations/agents"); match fs::read_dir(&agents_dir) { Ok(read_dir) => { - let mut agent_ids = Vec::new(); + let mut agent_ids = std::collections::BTreeSet::new(); for entry in read_dir { let entry = entry.map_err(|error| { format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display()) })?; let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { - continue; - } - if let Some(agent_id) = path.file_stem().and_then(|value| value.to_str()) { - agent_ids.push(agent_id.to_string()); + if path.extension().and_then(|value| value.to_str()) == Some("jsonl") { + if let Some(agent_id) = + path.file_stem().and_then(|value| value.to_str()) + { + agent_ids.insert(agent_id.to_string()); + } + } else if path.is_dir() { + if let Some(agent_id) = + path.file_name().and_then(|value| value.to_str()) + { + agent_ids.insert(agent_id.to_string()); + } } } - agent_ids.sort(); for agent_id in agent_ids { push_conversation_entries( &mut entries, - read_local_conversation_at(root, Some(&agent_id))?, + read_local_conversation_for_session_at(root, Some(&agent_id), None)?, &agent_id, ); } @@ -10918,10 +11272,15 @@ pub(crate) fn render_local_conversation_prompt_context( } else { push_conversation_entries( &mut entries, - read_local_conversation_at(root, Some(agent_id))?, + read_local_conversation_for_session_at(root, Some(agent_id), session_id)?, agent_id, ); } + } else if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("项目主对话不接受 sessionId".to_string()); } if entries.is_empty() { 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 3d750cc67..c933ca02a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -241,6 +241,7 @@ pub(crate) async fn chat_with_game_creator_agent( pub(crate) async fn chat_with_game_creator_role_agent( project_path: String, agent_id: String, + session_id: Option, prompt: String, ) -> Result { let root = Path::new(project_path.trim()); @@ -248,9 +249,15 @@ pub(crate) async fn chat_with_game_creator_role_agent( enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; - chat_with_game_creator_role_agent_runtime_at(root, &agent_id, prompt.trim(), "") - .await - .map(|(reply, _runtime)| reply) + chat_with_game_creator_role_agent_runtime_for_session_at( + root, + &agent_id, + session_id.as_deref(), + prompt.trim(), + "", + ) + .await + .map(|(reply, _runtime)| reply) } #[tauri::command] @@ -258,6 +265,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( app: tauri::AppHandle, project_path: String, agent_id: String, + session_id: Option, prompt: String, run_id: String, ) -> Result { @@ -265,6 +273,8 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; let run_id = run_id.trim().to_string(); let root = Path::new(project_path.as_str()); + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; @@ -272,8 +282,13 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( let event_project_path = project_path.clone(); let event_agent_id = agent_id.clone(); let event_run_id = run_id.clone(); - let mut runtime_state = - start_game_creator_agent_runtime_turn_at(root, agent_id.as_str(), prompt.trim(), &run_id)?; + let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + &run_id, + )?; runtime_state = advance_game_creator_agent_runtime_turn_at( root, runtime_state, @@ -300,9 +315,10 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( runtime_state: Some(runtime_state.clone()), }, ); - let result = chat_with_game_creator_role_agent_stream_at( + let result = chat_with_game_creator_role_agent_stream_for_session_at( root, agent_id.as_str(), + Some(&session_id), prompt.trim(), |delta| { let _ = emit_app.emit( @@ -315,7 +331,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( delta_text: delta.delta_text.clone(), accumulated_text: delta.accumulated_text.clone(), finish_reason: delta.finish_reason.clone(), - session_id: None, + session_id: Some(streaming_runtime_state.session_id.clone()), runtime_status: Some("running".to_string()), runtime_phase: Some("llm".to_string()), runtime_summary: Some("正在接收 Agent 回复".to_string()), @@ -383,6 +399,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( pub(crate) fn start_game_creator_agent_runtime_task( project_path: String, agent_id: String, + session_id: Option, task: String, run_id: String, ) -> Result { @@ -390,7 +407,13 @@ pub(crate) fn start_game_creator_agent_runtime_task( enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; - start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim()) + start_game_creator_agent_background_task_for_session_at( + root, + agent_id.trim(), + session_id.as_deref(), + task.trim(), + run_id.trim(), + ) } #[tauri::command] @@ -476,10 +499,11 @@ pub(crate) fn reject_game_creator_agent_runtime_task( pub(crate) fn read_game_creator_agent_runtime( project_path: String, agent_id: String, + session_id: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtime_at(root, agent_id.trim()) + read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) } #[tauri::command] @@ -827,26 +851,82 @@ pub(crate) fn delete_local_game_memory( delete_local_game_memory_at(root, scope.trim()) } +#[tauri::command] +pub(crate) fn list_game_creator_agent_sessions( + project_path: String, + agent_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + list_game_creator_agent_sessions_at(root, agent_id.trim()) +} + +#[tauri::command] +pub(crate) fn create_game_creator_agent_session( + project_path: String, + agent_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")?; + create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) +} + +#[tauri::command] +pub(crate) fn set_active_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: 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")?; + set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) fn archive_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: 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")?; + archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + #[tauri::command] pub(crate) fn read_local_conversation( project_path: String, agent_id: Option, + session_id: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - read_local_conversation_at(root, agent_id.as_deref()) + read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, agent_id: Option, + session_id: Option, message: LocalConversationMessage, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_at(root, agent_id.as_deref(), message) + append_local_conversation_message_for_session_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + ) } #[tauri::command] 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 ce3bdbe8d..3c258ba11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -665,9 +665,31 @@ struct LocalConversationMessageRecord { struct LocalConversationResult { path: String, agent_id: Option, + session_id: Option, messages: Vec, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentConversationSessionRecord { + session_id: String, + title: String, + created_at: u64, + updated_at: u64, + archived_at: Option, + message_count: u64, + legacy: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentConversationSessionListResult { + path: String, + agent_id: String, + active_session_id: String, + sessions: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ProjectPermissionPolicy { @@ -808,6 +830,7 @@ const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json"; const PROJECT_INDEX_PATH: &str = ".agent/project.index.json"; const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock"; const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; +const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1"; const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1"; const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20; const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12; @@ -1254,6 +1277,10 @@ fn main() { write_local_agent_memory, write_local_game_memory, delete_local_game_memory, + list_game_creator_agent_sessions, + create_game_creator_agent_session, + set_active_game_creator_agent_session, + archive_game_creator_agent_session, read_local_conversation, append_local_conversation_message, build_local_project_index, 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 59b8e94e6..c470c406c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -278,11 +278,582 @@ pub(crate) fn delete_local_game_memory_at( } } -pub(crate) fn read_local_conversation_at( +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentConversationSessionCatalogFile { + schema_version: String, + agent_id: String, + active_session_id: String, + sessions: Vec, +} + +pub(crate) fn legacy_agent_conversation_session_id(agent_id: &str) -> String { + format!("agent-session-{agent_id}") +} + +pub(crate) fn normalize_agent_conversation_session_id(session_id: &str) -> Result { + let session_id = session_id.trim(); + if session_id.is_empty() + || session_id.contains("..") + || session_id + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')) + { + return Err("session id 只能包含 ASCII 字母、数字、短横线和下划线".to_string()); + } + Ok(session_id.to_string()) +} + +fn agent_conversation_session_catalog_path(root: &Path, agent_id: &str) -> PathBuf { + root.join(".agent/runtime/sessions") + .join(format!("{agent_id}.json")) +} + +fn agent_conversation_sessions_dir(root: &Path, agent_id: &str) -> PathBuf { + root.join(".agent/conversations/agents") + .join(agent_id) + .join("sessions") +} + +fn conversation_file_path_for_resolved_session( + root: &Path, + agent_id: &str, + session_id: &str, +) -> PathBuf { + if session_id == legacy_agent_conversation_session_id(agent_id) { + root.join(".agent/conversations/agents") + .join(format!("{agent_id}.jsonl")) + } else { + agent_conversation_sessions_dir(root, agent_id).join(format!("{session_id}.jsonl")) + } +} + +fn file_modified_timestamp(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn count_conversation_messages(path: &Path) -> Result { + match File::open(path) { + Ok(file) => { + let mut count = 0_u64; + for line in BufReader::new(file).lines() { + let line = + line.map_err(|error| format!("读取对话记录失败:{}: {error}", path.display()))?; + if !line.trim().is_empty() { + count = count.saturating_add(1); + } + } + Ok(count) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(format!("读取对话记录失败:{}: {error}", path.display())), + } +} + +fn default_agent_conversation_session_catalog( + root: &Path, + agent_id: &str, +) -> AgentConversationSessionCatalogFile { + let legacy_session_id = legacy_agent_conversation_session_id(agent_id); + let legacy_path = + conversation_file_path_for_resolved_session(root, agent_id, &legacy_session_id); + let updated_at = file_modified_timestamp(&legacy_path); + AgentConversationSessionCatalogFile { + schema_version: AGENT_CONVERSATION_SESSION_SCHEMA_VERSION.to_string(), + agent_id: agent_id.to_string(), + active_session_id: legacy_session_id.clone(), + sessions: vec![AgentConversationSessionRecord { + session_id: legacy_session_id, + title: "默认会话".to_string(), + created_at: updated_at, + updated_at, + archived_at: None, + message_count: count_conversation_messages(&legacy_path).unwrap_or(0), + legacy: true, + }], + } +} + +fn read_agent_conversation_session_catalog_unlocked( + root: &Path, + agent_id: &str, +) -> Result { + validate_project_root(root)?; + let agent_id = normalize_conversation_agent_id(agent_id)?; + let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + let mut catalog = match fs::read_to_string(&catalog_path) { + Ok(content) => serde_json::from_str::(&content) + .map_err(|error| { + format!( + "解析 Agent Session 目录失败:{}: {error}", + catalog_path.display() + ) + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + default_agent_conversation_session_catalog(root, &agent_id) + } + Err(error) => { + return Err(format!( + "读取 Agent Session 目录失败:{}: {error}", + catalog_path.display() + )); + } + }; + if catalog.agent_id.trim().is_empty() { + catalog.agent_id = agent_id.clone(); + } + if catalog.agent_id != agent_id { + return Err(format!( + "Agent Session 目录归属不一致:期望 {agent_id},实际 {}", + catalog.agent_id + )); + } + if catalog.schema_version.trim().is_empty() { + catalog.schema_version = AGENT_CONVERSATION_SESSION_SCHEMA_VERSION.to_string(); + } + + let legacy_session_id = legacy_agent_conversation_session_id(&agent_id); + let mut seen = std::collections::BTreeSet::new(); + for session in &mut catalog.sessions { + session.session_id = normalize_agent_conversation_session_id(&session.session_id)?; + if !seen.insert(session.session_id.clone()) { + return Err(format!( + "Agent Session 目录包含重复 sessionId:{}", + session.session_id + )); + } + session.legacy = session.session_id == legacy_session_id; + if session.title.trim().is_empty() { + session.title = if session.legacy { + "默认会话".to_string() + } else { + "恢复会话".to_string() + }; + } + let conversation_path = + conversation_file_path_for_resolved_session(root, &agent_id, &session.session_id); + session.message_count = count_conversation_messages(&conversation_path)?; + session.updated_at = session + .updated_at + .max(file_modified_timestamp(&conversation_path)); + } + if !seen.contains(&legacy_session_id) { + let legacy_path = + conversation_file_path_for_resolved_session(root, &agent_id, &legacy_session_id); + let updated_at = file_modified_timestamp(&legacy_path); + catalog.sessions.insert( + 0, + AgentConversationSessionRecord { + session_id: legacy_session_id.clone(), + title: "默认会话".to_string(), + created_at: updated_at, + updated_at, + archived_at: None, + message_count: count_conversation_messages(&legacy_path)?, + legacy: true, + }, + ); + seen.insert(legacy_session_id.clone()); + } + + let sessions_dir = agent_conversation_sessions_dir(root, &agent_id); + match fs::read_dir(&sessions_dir) { + Ok(read_dir) => { + for entry in read_dir { + let entry = entry.map_err(|error| { + format!( + "读取 Agent Session 对话目录失败:{}: {error}", + sessions_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + let Some(session_id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let session_id = normalize_agent_conversation_session_id(session_id)?; + if seen.insert(session_id.clone()) { + let updated_at = file_modified_timestamp(&path); + catalog.sessions.push(AgentConversationSessionRecord { + session_id, + title: "恢复会话".to_string(), + created_at: updated_at, + updated_at, + archived_at: None, + message_count: count_conversation_messages(&path)?, + legacy: false, + }); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent Session 对话目录失败:{}: {error}", + sessions_dir.display() + )); + } + } + + let active_is_valid = catalog.sessions.iter().any(|session| { + session.session_id == catalog.active_session_id && session.archived_at.is_none() + }); + if !active_is_valid { + catalog.active_session_id = catalog + .sessions + .iter() + .find(|session| session.archived_at.is_none()) + .map(|session| session.session_id.clone()) + .unwrap_or_else(|| legacy_session_id.clone()); + } + catalog.sessions.sort_by(|left, right| { + right + .legacy + .cmp(&left.legacy) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + Ok(catalog) +} + +fn write_agent_conversation_session_catalog_unlocked( + root: &Path, + catalog: &AgentConversationSessionCatalogFile, +) -> Result<(), String> { + let path = agent_conversation_session_catalog_path(root, &catalog.agent_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建 Agent Session 目录失败:{}: {error}", parent.display()) + })?; + } + let content = serde_json::to_string_pretty(catalog) + .map_err(|error| format!("序列化 Agent Session 目录失败:{error}"))?; + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("sessions.json"), + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::write(&temp_path, format!("{content}\n")).map_err(|error| { + format!( + "写入 Agent Session 临时目录失败:{}: {error}", + temp_path.display() + ) + })?; + fs::rename(&temp_path, &path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "替换 Agent Session 目录失败:{} -> {}: {error}", + temp_path.display(), + path.display() + ) + }) +} + +fn agent_conversation_session_list_result( + root: &Path, + catalog: AgentConversationSessionCatalogFile, +) -> AgentConversationSessionListResult { + AgentConversationSessionListResult { + path: agent_conversation_session_catalog_path(root, &catalog.agent_id) + .to_string_lossy() + .into_owned(), + agent_id: catalog.agent_id, + active_session_id: catalog.active_session_id, + sessions: catalog.sessions, + } +} + +pub(crate) fn list_game_creator_agent_sessions_at( + root: &Path, + agent_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + read_agent_conversation_session_catalog_unlocked(root, &agent_id) + .map(|catalog| agent_conversation_session_list_result(root, catalog)) +} + +fn ensure_agent_session_has_no_live_tasks( + root: &Path, + agent_id: &str, + session_id: &str, +) -> Result<(), String> { + let mut latest_by_run = BTreeMap::::new(); + let task_path = root + .join(".agent/runtime/tasks") + .join(format!("{agent_id}.jsonl")); + match File::open(&task_path) { + Ok(file) => { + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!( + "读取 Agent Runtime 任务失败:{}: {error}", + task_path.display() + ) + })?; + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(record) = serde_json::from_str::(line) { + latest_by_run.insert(record.run_id.clone(), record); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent Runtime 任务失败:{}: {error}", + task_path.display() + )); + } + } + 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") + }); + if let Some(record) = blocked { + return Err(format!( + "Session {} 仍有未结束任务 {}({} / {}),不能切换或归档", + session_id, record.run_id, record.status, record.phase + )); + } + Ok(()) +} + +pub(crate) fn create_game_creator_agent_session_at( + root: &Path, + agent_id: &str, + 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() + .map_err(|_| "获取 Agent Session 目录锁失败:锁已损坏".to_string())?; + 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) + .map(|attempt| { + format!( + "agent-session-{agent_id}-{}-{attempt}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ) + }) + .find(|candidate| { + !catalog + .sessions + .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(), + 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)) +} + +pub(crate) fn set_active_game_creator_agent_session_at( + root: &Path, + agent_id: &str, + session_id: &str, +) -> 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() + .map_err(|_| "获取 Agent Session 目录锁失败:锁已损坏".to_string())?; + 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( + root: &Path, + agent_id: &str, + session_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = normalize_agent_conversation_session_id(session_id)?; + 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() + .map_err(|_| "获取 Agent Session 目录锁失败:锁已损坏".to_string())?; + 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 + .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( + root: &Path, + agent_id: &str, + session_id: Option<&str>, +) -> Result { + let catalog = read_agent_conversation_session_catalog_unlocked(root, agent_id)?; + let session_id = match session_id.map(str::trim).filter(|value| !value.is_empty()) { + Some(session_id) => normalize_agent_conversation_session_id(session_id)?, + None => catalog.active_session_id.clone(), + }; + catalog + .sessions + .into_iter() + .find(|session| session.session_id == session_id) + .ok_or_else(|| format!("Agent Session 不存在:{session_id}")) +} + +pub(crate) fn resolve_agent_conversation_session_id_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + require_writable: bool, +) -> Result { + validate_project_root(root)?; + let agent_id = normalize_conversation_agent_id(agent_id)?; + let session = resolve_agent_conversation_session_at(root, &agent_id, session_id)?; + if require_writable && session.archived_at.is_some() { + return Err(format!( + "Agent Session 已归档,只能读取:{}", + session.session_id + )); + } + Ok(session.session_id) +} + +fn touch_agent_conversation_session_at( + root: &Path, + agent_id: &str, + session_id: &str, + message_count: u64, +) -> Result<(), String> { + let catalog_path = agent_conversation_session_catalog_path(root, agent_id); + let lock = project_append_lock_for(&catalog_path)?; + let _guard = lock + .lock() + .map_err(|_| "获取 Agent Session 目录锁失败:锁已损坏".to_string())?; + let mut catalog = read_agent_conversation_session_catalog_unlocked(root, agent_id)?; + let session = catalog + .sessions + .iter_mut() + .find(|session| session.session_id == session_id) + .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; + session.message_count = message_count; + session.updated_at = unix_timestamp(); + write_agent_conversation_session_catalog_unlocked(root, &catalog) +} + +pub(crate) fn read_local_conversation_for_session_at( root: &Path, agent_id: Option<&str>, + session_id: Option<&str>, ) -> Result { - let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?; + validate_project_root(root)?; + let (path, normalized_agent_id, normalized_session_id) = match agent_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(agent_id) => { + let agent_id = normalize_conversation_agent_id(agent_id)?; + let session = resolve_agent_conversation_session_at(root, &agent_id, session_id)?; + let path = + conversation_file_path_for_resolved_session(root, &agent_id, &session.session_id); + (path, Some(agent_id), Some(session.session_id)) + } + None => { + if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("项目主对话不接受 sessionId".to_string()); + } + (root.join(".agent/conversations/project.jsonl"), None, None) + } + }; let mut messages = Vec::new(); match File::open(&path) { Ok(file) => { @@ -305,13 +876,22 @@ pub(crate) fn read_local_conversation_at( Ok(LocalConversationResult { path: path.to_string_lossy().into_owned(), agent_id: normalized_agent_id, + session_id: normalized_session_id, messages, }) } -pub(crate) fn append_local_conversation_message_at( +pub(crate) fn read_local_conversation_at( root: &Path, agent_id: Option<&str>, +) -> Result { + read_local_conversation_for_session_at(root, agent_id, None) +} + +pub(crate) fn append_local_conversation_message_for_session_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, message: LocalConversationMessage, ) -> Result { let LocalConversationMessage { @@ -319,14 +899,50 @@ pub(crate) fn append_local_conversation_message_at( content, agent_id: _client_agent_id, } = message; - let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?; + let (path, normalized_agent_id, normalized_session_id, archived) = match agent_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(agent_id) => { + let agent_id = normalize_conversation_agent_id(agent_id)?; + let session = resolve_agent_conversation_session_at(root, &agent_id, session_id)?; + let path = + conversation_file_path_for_resolved_session(root, &agent_id, &session.session_id); + ( + path, + Some(agent_id), + Some(session.session_id), + session.archived_at.is_some(), + ) + } + None => { + if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("项目主对话不接受 sessionId".to_string()); + } + ( + root.join(".agent/conversations/project.jsonl"), + None, + None, + false, + ) + } + }; + if archived { + return Err(format!( + "Agent Session 已归档,只能读取:{}", + normalized_session_id.as_deref().unwrap_or_default() + )); + } let role = role.trim(); if !matches!(role, "user" | "assistant" | "tool") { return Err("对话角色必须是 user、assistant 或 tool".to_string()); } let content = content.trim(); if content.is_empty() { - return read_local_conversation_at(root, agent_id); + return read_local_conversation_for_session_at(root, agent_id, session_id); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -347,27 +963,63 @@ pub(crate) fn append_local_conversation_message_at( serde_json::json!({ "recordType": "conversation.message", "agentId": normalized_agent_id, + "sessionId": normalized_session_id, "role": role, "path": relative_project_path(root, &path)?, }), )?; - read_local_conversation_at(root, agent_id) + let result = read_local_conversation_for_session_at(root, agent_id, session_id)?; + if let (Some(agent_id), Some(session_id)) = + (result.agent_id.as_deref(), result.session_id.as_deref()) + { + touch_agent_conversation_session_at( + root, + agent_id, + session_id, + result.messages.len() as u64, + )?; + } + Ok(result) +} + +pub(crate) fn append_local_conversation_message_at( + root: &Path, + agent_id: Option<&str>, + message: LocalConversationMessage, +) -> Result { + append_local_conversation_message_for_session_at(root, agent_id, None, message) +} + +pub(crate) fn conversation_file_path_for_session( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, +) -> Result<(PathBuf, Option, Option), String> { + validate_project_root(root)?; + let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else { + if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("项目主对话不接受 sessionId".to_string()); + } + return Ok((root.join(".agent/conversations/project.jsonl"), None, None)); + }; + let agent_id = normalize_conversation_agent_id(agent_id)?; + let session = resolve_agent_conversation_session_at(root, &agent_id, session_id)?; + Ok(( + conversation_file_path_for_resolved_session(root, &agent_id, &session.session_id), + Some(agent_id), + Some(session.session_id), + )) } pub(crate) fn conversation_file_path( root: &Path, agent_id: Option<&str>, ) -> Result<(PathBuf, Option), String> { - validate_project_root(root)?; - let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok((root.join(".agent/conversations/project.jsonl"), None)); - }; - let normalized = normalize_conversation_agent_id(agent_id)?; - Ok(( - root.join(".agent/conversations/agents") - .join(format!("{normalized}.jsonl")), - Some(normalized), - )) + let (path, agent_id, _session_id) = conversation_file_path_for_session(root, agent_id, None)?; + Ok((path, agent_id)) } pub(crate) fn normalize_conversation_agent_id(agent_id: &str) -> Result { 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 94f548cff..caf9b5a85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -11067,6 +11067,484 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + append_local_conversation_message_at( + &root, + Some("design-director"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "legacy history".to_string(), + agent_id: None, + }, + ) + .expect("append legacy history"); + let legacy = + list_game_creator_agent_sessions_at(&root, "design-director").expect("list legacy session"); + assert_eq!(legacy.sessions.len(), 1); + assert_eq!(legacy.active_session_id, "agent-session-design-director"); + assert!(legacy.sessions[0].legacy); + assert_eq!(legacy.sessions[0].message_count, 1); + + let created = create_game_creator_agent_session_at(&root, "design-director", "角色规范") + .expect("create session"); + let new_session_id = created.active_session_id.clone(); + assert_ne!(new_session_id, "agent-session-design-director"); + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&new_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "new session only".to_string(), + agent_id: None, + }, + ) + .expect("append new session"); + + let legacy_history = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some("agent-session-design-director"), + ) + .expect("read legacy"); + let new_history = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&new_session_id), + ) + .expect("read new session"); + assert_eq!(legacy_history.messages.len(), 1); + assert_eq!(legacy_history.messages[0].content, "legacy history"); + assert_eq!(new_history.messages.len(), 1); + assert_eq!(new_history.messages[0].content, "new session only"); + assert!(legacy_history.path.ends_with("design-director.jsonl")); + assert!(new_history + .path + .ends_with(&format!("design-director/sessions/{new_session_id}.jsonl"))); + let legacy_context = render_local_conversation_prompt_context_for_session( + &root, + Some("design-director"), + Some("agent-session-design-director"), + ) + .expect("render legacy context"); + assert!(legacy_context.contains("legacy history")); + assert!(!legacy_context.contains("new session only")); + let new_context = render_local_conversation_prompt_context_for_session( + &root, + Some("design-director"), + Some(&new_session_id), + ) + .expect("render new session context"); + assert!(new_context.contains("new session only")); + assert!(!new_context.contains("legacy history")); + + let listed = list_game_creator_agent_sessions_at(&root, "design-director") + .expect("reload session catalog"); + assert_eq!(listed.active_session_id, new_session_id); + assert_eq!( + listed + .sessions + .iter() + .find(|session| session.session_id == listed.active_session_id) + .map(|session| session.message_count), + Some(1) + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let created = create_game_creator_agent_session_at(&root, "design-director", "待归档") + .expect("create session"); + let session_id = created.active_session_id.clone(); + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "archive me".to_string(), + agent_id: None, + }, + ) + .expect("append before archive"); + + let archived = archive_game_creator_agent_session_at(&root, "design-director", &session_id) + .expect("archive session"); + assert_eq!(archived.active_session_id, "agent-session-design-director"); + assert!(archived + .sessions + .iter() + .find(|session| session.session_id == session_id) + .and_then(|session| session.archived_at) + .is_some()); + let history = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("archived history remains readable"); + assert_eq!(history.messages[0].content, "archive me"); + let error = append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "should fail".to_string(), + agent_id: None, + }, + ) + .expect_err("archived session is read only"); + assert!(error.contains("已归档")); + assert!(archive_game_creator_agent_session_at( + &root, + "design-director", + "agent-session-design-director", + ) + .expect_err("legacy session cannot archive") + .contains("legacy")); + assert!(start_game_creator_agent_runtime_turn_for_session_at( + &root, + "design-director", + Some(&session_id), + "archived runtime must fail", + "archived-session-run", + ) + .expect_err("archived session cannot start runtime") + .contains("已归档")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_conversation_session_rejects_unsafe_ids() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + let error = + read_local_conversation_for_session_at(&root, Some("design-director"), Some("../other")) + .expect_err("unsafe session id"); + assert!(error.contains("session id")); + assert!( + set_active_game_creator_agent_session_at(&root, "design-director", "/tmp/other",) + .expect_err("absolute session id") + .contains("session id") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_session_views_filter_before_recent_limits() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let first = create_game_creator_agent_session_at(&root, "design-director", "第一会话") + .expect("create first session"); + let first_session_id = first.active_session_id.clone(); + let first_state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some(&first_session_id), + "first session task", + "first-session-run", + "agent-background-task", + "start first session task", + vec!["finish first session task".to_string()], + ) + .expect("start first session runtime"); + finish_game_creator_agent_runtime_turn_at(&root, first_state, "first session reply") + .expect("finish first session runtime"); + + let second = create_game_creator_agent_session_at(&root, "design-director", "第二会话") + .expect("create second session"); + let second_session_id = second.active_session_id.clone(); + for index in 0..14 { + let state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some(&second_session_id), + &format!("second session task {index}"), + &format!("second-session-run-{index}"), + "agent-background-task", + "start second session task", + vec!["finish second session task".to_string()], + ) + .expect("start second session runtime"); + finish_game_creator_agent_runtime_turn_at( + &root, + state, + &format!("second session reply {index}"), + ) + .expect("finish second session runtime"); + } + + let first_view = read_game_creator_agent_runtime_for_session_at( + &root, + "design-director", + Some(&first_session_id), + ) + .expect("read first session runtime"); + assert_eq!(first_view.state.session_id, first_session_id); + assert_eq!(first_view.state.status, "idle"); + assert_eq!(first_view.state.phase, "idle"); + assert!(first_view.state.current_task.is_empty()); + assert_eq!(first_view.task_queue.total, 1); + assert_eq!(first_view.recent_tasks.len(), 1); + assert!(!first_view.recent_events.is_empty()); + assert!(first_view + .recent_tasks + .iter() + .all(|task| task.session_id == first_view.state.session_id)); + assert!(first_view + .recent_events + .iter() + .all(|event| event.session_id == first_view.state.session_id)); + + let second_view = read_game_creator_agent_runtime_for_session_at( + &root, + "design-director", + Some(&second_session_id), + ) + .expect("read second session runtime"); + assert_eq!(second_view.state.session_id, second_session_id); + assert_eq!(second_view.task_queue.total, 14); + assert!(second_view + .recent_tasks + .iter() + .all(|task| task.session_id == second_view.state.session_id)); + assert!(second_view + .recent_events + .iter() + .all(|event| event.session_id == second_view.state.session_id)); + + let global_view = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read global agent runtime"); + assert_eq!(global_view.task_queue.total, 15); + assert_eq!( + global_view.recent_tasks.len(), + AGENT_RUNTIME_RECENT_TASK_LIMIT + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_conversation_tool_uses_run_session() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + append_local_conversation_message_at( + &root, + Some("design-director"), + LocalConversationMessage { + role: "user".to_string(), + content: "legacy session context".to_string(), + agent_id: None, + }, + ) + .expect("append legacy context"); + let active = create_game_creator_agent_session_at(&root, "design-director", "当前会话") + .expect("create active session"); + append_local_conversation_message_for_session_at( + &root, + Some("design-director"), + Some(&active.active_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "active session context".to_string(), + agent_id: None, + }, + ) + .expect("append active context"); + let state = start_game_creator_agent_runtime_task_for_session_at( + &root, + "design-director", + Some("agent-session-design-director"), + "read original conversation", + "conversation-session-run", + "agent-background-task", + "read conversation", + vec!["read conversation".to_string()], + ) + .expect("start runtime in legacy session"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: active.active_session_id.clone(), + run_id: "other-session-task".to_string(), + source: "agent-background-task".to_string(), + task: "must stay outside current run status".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "failed".to_string(), + error: Some("other session failure".to_string()), + updated_at: unix_timestamp(), + }, + ); + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + &state.run_id, + &state.current_task, + &AgentRuntimeToolAction { + tool: "conversation.read".to_string(), + reason: Some("verify captured session".to_string()), + input: serde_json::json!({}), + }, + ) + .await; + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("conversation observation detail"); + assert!(detail.contains("legacy session context")); + assert!(!detail.contains("active session context")); + let status_observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + &state.run_id, + &state.current_task, + &AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("verify session-scoped runtime status".to_string()), + input: serde_json::json!({ "scope": "self" }), + }, + ) + .await; + assert_eq!(status_observation.status, "ok"); + let status_detail = status_observation.detail.expect("runtime status detail"); + assert!(status_detail.contains("任务队列: total=1")); + assert!(!status_detail.contains("must stay outside current run status")); + finish_game_creator_agent_runtime_turn_at(&root, state, "done") + .expect("finish conversation runtime"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_background_queue_captures_explicit_session() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let first = create_game_creator_agent_session_at(&root, "design-director", "排队会话") + .expect("create queued session"); + let first_session_id = first.active_session_id.clone(); + let second = create_game_creator_agent_session_at(&root, "design-director", "当前会话") + .expect("create active session"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire agent runtime lock") + .expect("runtime lock available"); + + let queued = start_game_creator_agent_background_task_for_session_at( + &root, + "design-director", + Some(&first_session_id), + "queued in first session", + "captured-session-run", + ) + .expect("queue background task"); + assert_eq!(queued.state.session_id, first_session_id); + assert_eq!(queued.task_queue.pending, 1); + assert!(queued + .recent_tasks + .iter() + .all(|task| { task.session_id == queued.state.session_id && task.status == "pending" })); + let first_conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&queued.state.session_id), + ) + .expect("read queued session conversation"); + assert_eq!(first_conversation.messages.len(), 1); + assert_eq!( + first_conversation.messages[0].content, + "queued in first session" + ); + let second_conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&second.active_session_id), + ) + .expect("read active session conversation"); + assert!(second_conversation.messages.is_empty()); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_retry_preserves_original_session() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let original = create_game_creator_agent_session_at(&root, "design-director", "原任务会话") + .expect("create original session"); + let original_session_id = original.active_session_id.clone(); + let active = create_game_creator_agent_session_at(&root, "design-director", "当前会话") + .expect("create current session"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: original_session_id.clone(), + run_id: "failed-original-run".to_string(), + source: "agent-background-task".to_string(), + task: "retry in original session".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "failed".to_string(), + error: Some("test failure".to_string()), + updated_at: unix_timestamp(), + }, + ); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire agent runtime lock") + .expect("runtime lock available"); + + let retried = retry_game_creator_agent_runtime_task_at( + &root, + "design-director", + "failed-original-run", + "retry-original-session-run", + ) + .expect("retry task"); + assert_eq!(retried.state.session_id, original_session_id); + assert_eq!(retried.task_queue.failed, 1); + assert_eq!(retried.task_queue.pending, 1); + assert!(retried.recent_tasks.iter().any(|task| { + task.run_id == "retry-original-session-run" + && task.session_id == retried.state.session_id + && task.status == "pending" + })); + let original_conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&retried.state.session_id), + ) + .expect("read original session conversation"); + assert_eq!(original_conversation.messages.len(), 1); + assert_eq!( + original_conversation.messages[0].content, + "retry in original session" + ); + let active_conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&active.active_session_id), + ) + .expect("read current session conversation"); + assert!(active_conversation.messages.is_empty()); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_conversation_prompt_context_scopes_agent_messages() { let root = unique_project_path(); @@ -11290,6 +11768,7 @@ fn local_conversation_write_respects_project_policy() { let error = append_local_conversation_message( root.to_string_lossy().into_owned(), None, + None, LocalConversationMessage { role: "user".to_string(), content: "should fail".to_string(), @@ -11326,7 +11805,7 @@ fn local_conversation_read_respects_project_policy() { ) .expect("write policy"); - let error = read_local_conversation(root.to_string_lossy().into_owned(), None) + let error = read_local_conversation(root.to_string_lossy().into_owned(), None, None) .expect_err("conversation read denied"); assert!(error.contains("项目权限策略拒绝执行:conversation.read")); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1672eb50e..417b004ee 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -12,6 +12,7 @@ import { useState, } from 'react'; import { + Archive, Bell, BookOpen, CircleHelp, @@ -541,9 +542,27 @@ interface LocalConversationMessageRecord { updatedAt: number; } +interface AgentConversationSessionRecord { + sessionId: string; + title: string; + createdAt: number; + updatedAt: number; + archivedAt: number | null; + messageCount: number; + legacy: boolean; +} + +interface AgentConversationSessionListResult { + path: string; + agentId: string; + activeSessionId: string; + sessions: AgentConversationSessionRecord[]; +} + interface LocalConversationResult { path: string; agentId: string | null; + sessionId?: string | null; messages: LocalConversationMessageRecord[]; } @@ -1264,6 +1283,20 @@ function resolveTauriInvoke() { return window.__TAURI__?.core?.invoke; } +function isMissingAgentSessionCommandError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return ( + normalized.includes('list_game_creator_agent_sessions') && + (normalized.includes('not found') || + normalized.includes('unknown command') || + normalized.includes('unexpected invoke') || + normalized.includes('unexpected command') || + normalized.includes('不存在') || + normalized.includes('未找到')) + ); +} + function createDefaultChatMessages(): ChatMessage[] { return [ { @@ -2829,6 +2862,19 @@ export function WorkspaceLauncher({ const [agentChatMessages, setAgentChatMessages] = useState< LocalConversationMessageRecord[] >([]); + const [agentChatSessions, setAgentChatSessions] = useState< + AgentConversationSessionRecord[] + >([]); + const [agentChatSelectedSessionId, setAgentChatSelectedSessionId] = useState< + string | null + >(null); + const [agentChatActiveSessionId, setAgentChatActiveSessionId] = useState< + string | null + >(null); + const [agentChatLegacySessionMode, setAgentChatLegacySessionMode] = + useState(false); + const [agentChatConversationPath, setAgentChatConversationPath] = useState(''); + const [agentChatSessionStatus, setAgentChatSessionStatus] = useState(''); const [agentChatInput, setAgentChatInput] = useState(''); const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent'); const [agentChatBusy, setAgentChatBusy] = useState(false); @@ -2839,12 +2885,18 @@ export function WorkspaceLauncher({ useState('尚未检查 LLM 配置'); const [agentChatRuntime, setAgentChatRuntime] = useState(null); + const [agentChatActiveRuntime, setAgentChatActiveRuntime] = + useState(null); const [agentChatRuntimeError, setAgentChatRuntimeError] = useState(''); const agentChatLoadVersionRef = useRef(0); const agentChatProjectPathRef = useRef(agentChatProjectPath); agentChatProjectPathRef.current = agentChatProjectPath; const agentChatSelectedAgentIdRef = useRef(agentChatSelectedAgentId); agentChatSelectedAgentIdRef.current = agentChatSelectedAgentId; + const agentChatSelectedSessionIdRef = useRef(agentChatSelectedSessionId); + agentChatSelectedSessionIdRef.current = agentChatSelectedSessionId; + const agentChatActiveSessionIdRef = useRef(agentChatActiveSessionId); + agentChatActiveSessionIdRef.current = agentChatActiveSessionId; useEffect(() => { const invoke = resolveTauriInvoke(); @@ -2896,6 +2948,21 @@ export function WorkspaceLauncher({ ) { return; } + if ( + agentChatActiveSessionIdRef.current === null || + payload.runtime.state.sessionId === agentChatActiveSessionIdRef.current + ) { + setAgentChatActiveRuntime((current) => + agentRuntimeStateFromResult(payload.runtime, current), + ); + } + if ( + agentChatSelectedSessionIdRef.current !== null && + payload.runtime.state.sessionId !== + agentChatSelectedSessionIdRef.current + ) { + return; + } setAgentChatRuntime((current) => agentRuntimeStateFromResult(payload.runtime, current), ); @@ -3495,6 +3562,49 @@ export function WorkspaceLauncher({ ); } + function selectedLauncherAgentChatSession( + sessionId = agentChatSelectedSessionId, + ): AgentConversationSessionRecord | null { + if (!sessionId) { + return null; + } + return ( + agentChatSessions.find((session) => session.sessionId === sessionId) ?? + null + ); + } + + function agentChatSessionInvokeArgs(sessionId: string | null) { + return sessionId ? { sessionId } : {}; + } + + function resetAgentChatSessionView() { + setAgentChatSessions([]); + setAgentChatSelectedSessionId(null); + setAgentChatActiveSessionId(null); + setAgentChatLegacySessionMode(false); + setAgentChatActiveRuntime(null); + setAgentChatConversationPath(''); + setAgentChatSessionStatus(''); + setAgentChatMessages([]); + } + + function updateAgentChatSessionMessageCount( + sessionId: string | null, + messageCount: number, + ) { + if (!sessionId) { + return; + } + setAgentChatSessions((sessions) => + sessions.map((session) => + session.sessionId === sessionId + ? { ...session, messageCount, updatedAt: Date.now() } + : session, + ), + ); + } + function getCurrentAgentChatLlmWarning( agent = selectedLauncherAgentChatAgent(), ) { @@ -3541,6 +3651,9 @@ export function WorkspaceLauncher({ } async function handleAgentChatPickProjectDirectory() { + if (agentChatBusy || agentChatBackgroundBusy) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setAgentChatStatus('需要在 Tauri App 内运行'); @@ -3556,6 +3669,7 @@ export function WorkspaceLauncher({ return; } setAgentChatStatus('已选择项目目录'); + resetAgentChatSessionView(); setAgentChatProjectPath(selectedPath); void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath); } catch (error) { @@ -3566,6 +3680,8 @@ export function WorkspaceLauncher({ async function loadAgentChatConversation( agentId = agentChatSelectedAgentId, projectPath = agentChatProjectPath, + requestedSessionId?: string | null, + knownSessions?: AgentConversationSessionListResult | null, ) { const projectPathForChat = validateAgentChatProjectPath(projectPath); const agent = selectedLauncherAgentChatAgent(agentId); @@ -3580,36 +3696,126 @@ export function WorkspaceLauncher({ const loadVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = loadVersion; setAgentChatBusy(true); - setAgentChatRuntime(null); - setAgentChatRuntimeError(''); setAgentChatStatus('正在读取'); try { + let sessionList = knownSessions; + let sessionListError = ''; + if (sessionList === undefined) { + try { + const candidate = await invoke( + 'list_game_creator_agent_sessions', + { + projectPath: projectPathForChat, + agentId: agent.id, + }, + ); + if (candidate === undefined || candidate === null) { + sessionList = null; + sessionListError = '当前客户端后端不支持多会话命令'; + } else if (!Array.isArray(candidate.sessions)) { + setAgentChatSessionStatus('Agent 会话列表返回格式无效'); + setAgentChatStatus('Agent 会话列表返回格式无效,已停止读取'); + return; + } else { + sessionList = candidate; + } + } catch (error) { + if (!isMissingAgentSessionCommandError(error)) { + const message = + error instanceof Error ? error.message : String(error); + setAgentChatSessionStatus(`Agent 会话列表读取失败:${message}`); + setAgentChatStatus(`Agent 会话列表读取失败:${message}`); + return; + } + sessionList = null; + sessionListError = '当前客户端后端不支持多会话命令'; + } + } + if (agentChatLoadVersionRef.current !== loadVersion) { + return; + } + + let sessionId = requestedSessionId ?? null; + let resolvedActiveSessionId = agentChatActiveSessionId; + if (sessionList) { + const requestedExists = sessionList.sessions.some( + (session) => session.sessionId === sessionId, + ); + if (!requestedExists) { + sessionId = + sessionList.activeSessionId || + sessionList.sessions.find((session) => session.archivedAt === null) + ?.sessionId || + sessionList.sessions[0]?.sessionId || + null; + } + setAgentChatSessions(sessionList.sessions); + setAgentChatSelectedSessionId(sessionId); + resolvedActiveSessionId = sessionList.activeSessionId || null; + setAgentChatActiveSessionId(resolvedActiveSessionId); + setAgentChatLegacySessionMode(false); + setAgentChatSessionStatus( + sessionList.sessions.length > 0 + ? `共 ${sessionList.sessions.length} 个会话` + : '暂无会话', + ); + } else { + sessionId = null; + resolvedActiveSessionId = null; + setAgentChatSessions([]); + setAgentChatSelectedSessionId(null); + setAgentChatActiveSessionId(null); + setAgentChatLegacySessionMode(true); + setAgentChatSessionStatus( + sessionListError + ? `会话列表不可用,已使用旧版单会话:${sessionListError}` + : '已使用旧版单会话', + ); + } + + const selectedSessionIsActive = + sessionId === resolvedActiveSessionId || + (sessionId === null && resolvedActiveSessionId === null); + setAgentChatRuntime(null); + setAgentChatRuntimeError(''); + const result = await invoke( 'read_local_conversation', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionId), }, ); if (agentChatLoadVersionRef.current !== loadVersion) { return; } setAgentChatMessages(result.messages); + updateAgentChatSessionMessageCount(sessionId, result.messages.length); + setAgentChatConversationPath(result.path); try { const runtime = await invoke( 'read_game_creator_agent_runtime', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionId), }, ); if (agentChatLoadVersionRef.current === loadVersion) { - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + if (selectedSessionIsActive) { + setAgentChatActiveRuntime(runtimeState); + } setAgentChatRuntimeError(''); } } catch (error) { if (agentChatLoadVersionRef.current === loadVersion) { setAgentChatRuntime(null); + if (selectedSessionIsActive) { + setAgentChatActiveRuntime(null); + } setAgentChatRuntimeError( error instanceof Error ? error.message : String(error), ); @@ -3621,6 +3827,7 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages([]); + setAgentChatConversationPath(''); setAgentChatStatus(error instanceof Error ? error.message : String(error)); } finally { if (agentChatLoadVersionRef.current === loadVersion) { @@ -3629,12 +3836,183 @@ export function WorkspaceLauncher({ } } + async function handleAgentChatSelectSession( + session: AgentConversationSessionRecord, + ) { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + if ( + !projectPathForChat || + !agent || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + if (session.archivedAt !== null) { + await loadAgentChatConversation( + agent.id, + projectPathForChat, + session.sessionId, + { + path: '', + agentId: agent.id, + activeSessionId: + agentChatActiveSessionId ?? + agentChatSessions.find( + (candidate) => candidate.archivedAt === null, + )?.sessionId ?? + session.sessionId, + sessions: agentChatSessions, + }, + ); + return; + } + if ( + session.sessionId === agentChatSelectedSessionId || + session.sessionId === agentChatActiveSessionId + ) { + await loadAgentChatConversation( + agent.id, + projectPathForChat, + session.sessionId, + { + path: '', + agentId: agent.id, + activeSessionId: session.sessionId, + sessions: agentChatSessions, + }, + ); + return; + } + setAgentChatBusy(true); + setAgentChatStatus('正在切换会话'); + try { + const result = await invoke( + 'set_active_game_creator_agent_session', + { + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: session.sessionId, + }, + ); + await loadAgentChatConversation( + agent.id, + projectPathForChat, + session.sessionId, + result, + ); + } catch (error) { + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + setAgentChatBusy(false); + } + } + + async function handleAgentChatCreateSession() { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + if ( + !projectPathForChat || + !agent || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + setAgentChatBusy(true); + setAgentChatStatus('正在新建会话'); + try { + const result = await invoke( + 'create_game_creator_agent_session', + { + projectPath: projectPathForChat, + agentId: agent.id, + title: '', + }, + ); + await loadAgentChatConversation( + agent.id, + projectPathForChat, + result.activeSessionId, + result, + ); + } catch (error) { + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + setAgentChatBusy(false); + } + } + + async function handleAgentChatArchiveSession() { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + const session = selectedLauncherAgentChatSession(); + if ( + !projectPathForChat || + !agent || + !session || + session.legacy || + session.archivedAt !== null || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + setAgentChatBusy(true); + setAgentChatStatus('正在归档会话'); + try { + const result = await invoke( + 'archive_game_creator_agent_session', + { + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: session.sessionId, + }, + ); + await loadAgentChatConversation( + agent.id, + projectPathForChat, + result.activeSessionId, + result, + ); + } catch (error) { + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + setAgentChatBusy(false); + } + } + async function handleAgentChatSubmit(event: FormEvent) { event.preventDefault(); const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); + const sessionIdForChat = agentChatSelectedSessionId; + const selectedSession = selectedLauncherAgentChatSession(sessionIdForChat); const content = agentChatInput.trim(); - if (!projectPathForChat || !agent || !content || agentChatBusy) { + if ( + !projectPathForChat || + !agent || + !content || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话只能查看,请先新建或切换到活动会话'); return; } const llmWarning = getCurrentAgentChatLlmWarning(agent); @@ -3661,6 +4039,7 @@ export function WorkspaceLauncher({ { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForChat), message: { role: 'user', content, @@ -3673,6 +4052,10 @@ export function WorkspaceLauncher({ } const savedUserMessages = savedUserResult.messages; setAgentChatMessages(savedUserMessages); + updateAgentChatSessionMessageCount( + sessionIdForChat, + savedUserMessages.length, + ); setAgentChatStatus('正在连接 Agent LLM'); const streamRunId = createAgentChatRunId('launcher-agent-chat'); const listen = window.__TAURI__?.event?.listen; @@ -3694,6 +4077,9 @@ export function WorkspaceLauncher({ setAgentChatRuntime((current) => normalizeAgentRuntimeState(payload.runtimeState!, current), ); + setAgentChatActiveRuntime((current) => + normalizeAgentRuntimeState(payload.runtimeState!, current), + ); setAgentChatRuntimeError(''); } if (payload.status === 'started') { @@ -3741,6 +4127,7 @@ export function WorkspaceLauncher({ agentId: agent.id, prompt: content, runId: streamRunId, + ...agentChatSessionInvokeArgs(sessionIdForChat), }, ) : await invoke( @@ -3749,6 +4136,7 @@ export function WorkspaceLauncher({ projectPath: projectPathForChat, agentId: agent.id, prompt: content, + ...agentChatSessionInvokeArgs(sessionIdForChat), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { @@ -3760,10 +4148,13 @@ export function WorkspaceLauncher({ { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForChat), }, ); if (agentChatLoadVersionRef.current === saveVersion) { - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); } } catch (error) { @@ -3783,6 +4174,7 @@ export function WorkspaceLauncher({ { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForChat), message: { role: 'assistant', content: reply.replyText, @@ -3794,6 +4186,10 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(assistantResult.messages); + updateAgentChatSessionMessageCount( + sessionIdForChat, + assistantResult.messages.length, + ); setAgentChatStatus( `已保存 ${assistantResult.messages.length} 条:${assistantResult.path}`, ); @@ -3811,6 +4207,7 @@ export function WorkspaceLauncher({ { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForChat), message: { role: 'assistant', content: message, @@ -3822,6 +4219,10 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(errorResult.messages); + updateAgentChatSessionMessageCount( + sessionIdForChat, + errorResult.messages.length, + ); } catch { setAgentChatMessages([ ...savedUserResult.messages, @@ -3851,8 +4252,20 @@ export function WorkspaceLauncher({ async function handleAgentChatStartBackgroundTask() { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); + const sessionIdForTask = agentChatSelectedSessionId; + const selectedSession = selectedLauncherAgentChatSession(sessionIdForTask); const content = agentChatInput.trim(); - if (!projectPathForChat || !agent || !content || agentChatBackgroundBusy) { + if ( + !projectPathForChat || + !agent || + !content || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话不能启动任务,请先新建或切换会话'); return; } const llmWarning = getCurrentAgentChatLlmWarning(agent); @@ -3878,18 +4291,22 @@ export function WorkspaceLauncher({ agentId: agent.id, task: content, runId: createAgentChatRunId('launcher-agent-task'), + ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { return; } - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { @@ -3913,7 +4330,18 @@ export function WorkspaceLauncher({ async function handleAgentChatCancelRuntimeTask(runId: string) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); - if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) { + const selectedSession = selectedLauncherAgentChatSession(); + if ( + !projectPathForChat || + !agent || + !runId || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话只能查看 Runtime 历史'); return; } const invoke = resolveTauriInvoke(); @@ -3937,7 +4365,9 @@ export function WorkspaceLauncher({ if (agentChatLoadVersionRef.current !== saveVersion) { return; } - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); setAgentChatStatus(`已取消后台任务:${runId}`); } catch (error) { @@ -3955,7 +4385,19 @@ export function WorkspaceLauncher({ async function handleAgentChatRetryRuntimeTask(runId: string) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); - if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) { + const selectedSession = selectedLauncherAgentChatSession(); + const sessionIdForTask = agentChatSelectedSessionId; + if ( + !projectPathForChat || + !agent || + !runId || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话不能重试任务'); return; } const llmWarning = getCurrentAgentChatLlmWarning(agent); @@ -3985,13 +4427,16 @@ export function WorkspaceLauncher({ if (agentChatLoadVersionRef.current !== saveVersion) { return; } - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { @@ -4017,15 +4462,22 @@ export function WorkspaceLauncher({ ) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); + const selectedSession = selectedLauncherAgentChatSession(); + const sessionIdForTask = agentChatSelectedSessionId; if ( !projectPathForChat || !agent || !runId || !actionId || + agentChatBusy || agentChatBackgroundBusy ) { return; } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话不能确认工具动作'); + return; + } const llmWarning = getCurrentAgentChatLlmWarning(agent); if (llmWarning) { setAgentChatStatus(llmWarning); @@ -4054,13 +4506,16 @@ export function WorkspaceLauncher({ if (agentChatLoadVersionRef.current !== saveVersion) { return; } - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { @@ -4086,15 +4541,22 @@ export function WorkspaceLauncher({ ) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); + const selectedSession = selectedLauncherAgentChatSession(); + const sessionIdForTask = agentChatSelectedSessionId; if ( !projectPathForChat || !agent || !runId || !actionId || + agentChatBusy || agentChatBackgroundBusy ) { return; } + if (selectedSession && selectedSession.archivedAt !== null) { + setAgentChatStatus('已归档会话不能拒绝工具动作'); + return; + } const llmWarning = getCurrentAgentChatLlmWarning(agent); if (llmWarning) { setAgentChatStatus(llmWarning); @@ -4123,13 +4585,16 @@ export function WorkspaceLauncher({ if (agentChatLoadVersionRef.current !== saveVersion) { return; } - setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + const runtimeState = agentRuntimeStateFromResult(runtime); + setAgentChatRuntime(runtimeState); + setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', { projectPath: projectPathForChat, agentId: agent.id, + ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); if (agentChatLoadVersionRef.current !== saveVersion) { @@ -4205,6 +4670,26 @@ export function WorkspaceLauncher({ homeAgentModeItems[0]!; const ActiveHomeModeIcon = activeHomeMode.icon; const currentAgentChatAgent = selectedLauncherAgentChatAgent(); + const currentAgentChatSession = selectedLauncherAgentChatSession(); + const currentAgentChatSessionArchived = + currentAgentChatSession?.archivedAt !== null && + currentAgentChatSession?.archivedAt !== undefined; + const currentAgentChatSessionMutationBlocked = Boolean( + agentChatActiveRuntime && + (['running', 'pending', 'waiting-for-confirmation', 'cancelling'].includes( + agentChatActiveRuntime.status, + ) || + agentChatActiveRuntime.phase === 'needs-reconciliation' || + (agentChatActiveRuntime.taskQueue?.pending ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.running ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.waitingForConfirmation ?? 0) > 0), + ); + const currentAgentChatActiveSessions = agentChatSessions.filter( + (session) => session.archivedAt === null, + ); + const currentAgentChatArchivedSessions = agentChatSessions.filter( + (session) => session.archivedAt !== null, + ); const currentAgentChatLlmWarning = getCurrentAgentChatLlmWarning( currentAgentChatAgent, ); @@ -4712,7 +5197,7 @@ export function WorkspaceLauncher({
@@ -4738,28 +5229,38 @@ export function WorkspaceLauncher({ className="launcher-agent-chat-project" onSubmit={(event) => { event.preventDefault(); - void loadAgentChatConversation(); + void loadAgentChatConversation( + agentChatSelectedAgentId, + agentChatProjectPath, + agentChatSelectedSessionId, + ); }} >
-
@@ -4769,13 +5270,16 @@ export function WorkspaceLauncher({
- - {currentAgentChatAgent - ? `.agent/conversations/agents/${currentAgentChatAgent.id}.jsonl` - : '请选择 Agent'} - + {agentChatConversationPath || '请选择并读取会话'} +
+
+ + +
+
+ {currentAgentChatActiveSessions.map((session) => ( + + ))} + {currentAgentChatArchivedSessions.map((session) => ( + + ))} +
+ + {agentChatSessionStatus} + +
{currentAgentChatLlmWarning ? (
@@ -4816,20 +5401,41 @@ export function WorkspaceLauncher({ - void handleAgentChatCancelRuntimeTask(runId) + controlBusy={agentChatBusy || agentChatBackgroundBusy} + onCancelRuntimeTask={ + currentAgentChatSessionArchived + ? undefined + : (runId) => + void handleAgentChatCancelRuntimeTask(runId) } - onRetryRuntimeTask={(runId) => - void handleAgentChatRetryRuntimeTask(runId) + onRetryRuntimeTask={ + currentAgentChatSessionArchived + ? undefined + : (runId) => + void handleAgentChatRetryRuntimeTask(runId) } - onConfirmRuntimeTask={(runId, actionId) => - void handleAgentChatConfirmRuntimeTask(runId, actionId) + onConfirmRuntimeTask={ + currentAgentChatSessionArchived + ? undefined + : (runId, actionId) => + void handleAgentChatConfirmRuntimeTask( + runId, + actionId, + ) } - onRejectRuntimeTask={(runId, actionId) => - void handleAgentChatRejectRuntimeTask(runId, actionId) + onRejectRuntimeTask={ + currentAgentChatSessionArchived + ? undefined + : (runId, actionId) => + void handleAgentChatRejectRuntimeTask(runId, actionId) + } + onRefreshRuntime={() => + void loadAgentChatConversation( + agentChatSelectedAgentId, + agentChatProjectPath, + agentChatSelectedSessionId, + ) } - onRefreshRuntime={() => void loadAgentChatConversation()} />
@@ -4852,7 +5458,12 @@ export function WorkspaceLauncher({ > setAgentChatInput(event.currentTarget.value) @@ -4860,7 +5471,12 @@ export function WorkspaceLauncher({ /> @@ -4868,7 +5484,8 @@ export function WorkspaceLauncher({ type="button" disabled={ agentChatBackgroundBusy || - currentAgentChatLlmWarning !== null + currentAgentChatLlmWarning !== null || + currentAgentChatSessionArchived } onClick={() => void handleAgentChatStartBackgroundTask()} > diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index af407fcaf..eaf85720b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -286,6 +286,7 @@ textarea { .launcher-main { position: relative; display: grid; + grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto; align-content: start; min-width: 0; @@ -1039,10 +1040,29 @@ textarea { padding-top: 92px; } +.launcher-agent-chat-page > header .launcher-project-list-actions button { + height: 34px; + padding: 0 12px; + border: 1px solid #d8dde5; + border-radius: 6px; + background: #fff; + color: #111827; + font-size: 12px; + white-space: nowrap; +} + +.launcher-agent-chat-page > header .launcher-project-list-actions button:last-child { + border-color: #111827; + background: #111827; + color: #fff; +} + .launcher-agent-chat-layout { display: grid; grid-template-columns: minmax(220px, 0.3fr) minmax(0, 1fr); gap: 12px; + width: 100%; + min-width: 0; min-height: min(620px, calc(100vh - 168px)); } @@ -1129,7 +1149,7 @@ textarea { .launcher-agent-chat-main { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; overflow: hidden; } @@ -1157,6 +1177,84 @@ textarea { font-size: 12px; } +.launcher-agent-session-bar { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 12px; + border-bottom: 1px solid #e5e7eb; + background: #f8fafc; +} + +.launcher-agent-session-actions, +.launcher-agent-session-list { + display: flex; + align-items: center; + gap: 6px; +} + +.launcher-agent-session-actions button { + display: grid; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid #d8dde5; + border-radius: 6px; + background: #fff; + color: #374151; + place-items: center; +} + +.launcher-agent-session-list { + min-width: 0; + overflow-x: auto; +} + +.launcher-agent-session-list button { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; + max-width: 190px; + height: 30px; + padding: 0 9px; + border: 1px solid #d8dde5; + border-radius: 6px; + background: #fff; + color: #374151; +} + +.launcher-agent-session-list button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-agent-session-list button small { + flex: 0 0 auto; +} + +.launcher-agent-session-list .launcher-agent-session-active { + border-color: #7c3aed; + background: #f5f0ff; + color: #5b21b6; +} + +.launcher-agent-session-list .launcher-agent-session-archived { + border-style: dashed; + color: #6b7280; +} + +.launcher-agent-session-status { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .launcher-agent-chat-messages { display: grid; align-content: start; @@ -1596,11 +1694,44 @@ textarea { width: min(100%, calc(100vw - 76px)); } + .launcher-agent-chat-page > header, + .launcher-agent-chat-main > header { + align-items: stretch; + flex-direction: column; + gap: 10px; + } + + .launcher-agent-chat-main > header > small { + max-width: 100%; + overflow-wrap: anywhere; + } + .launcher-showcase-grid, .launcher-development-grid { grid-template-columns: 1fr; } + .launcher-agent-chat-layout { + grid-template-columns: 1fr; + min-height: auto; + } + + .launcher-agent-picker { + max-height: 240px; + } + + .launcher-agent-chat-main { + min-height: 620px; + } + + .launcher-agent-session-bar { + grid-template-columns: auto minmax(0, 1fr); + } + + .launcher-agent-session-status { + display: none; + } + .launcher-development-assets div { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index e3f25fad5..3f5f0981f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1063,6 +1063,217 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('creates, switches, archives, and isolates developer Agent sessions', async () => { + type SessionRecord = { + sessionId: string; + title: string; + createdAt: number; + updatedAt: number; + archivedAt: number | null; + messageCount: number; + legacy: boolean; + }; + const legacySessionId = 'agent-session-design-director'; + const roleSessionId = 'agent-session-design-director-role-spec'; + const createdSessionId = 'agent-session-design-director-created'; + let activeSessionId = roleSessionId; + const sessions: SessionRecord[] = [ + { + sessionId: legacySessionId, + title: '默认会话', + createdAt: 1, + updatedAt: 1, + archivedAt: null, + messageCount: 1, + legacy: true, + }, + { + sessionId: roleSessionId, + title: '角色规范', + createdAt: 2, + updatedAt: 2, + archivedAt: null, + messageCount: 1, + legacy: false, + }, + ]; + const messages = new Map>([ + [legacySessionId, [{ role: 'assistant', content: '默认会话历史' }]], + [roleSessionId, [{ role: 'assistant', content: '角色规范历史' }]], + ]); + const sessionResult = () => ({ + path: '/tmp/authorized-game/.agent/runtime/sessions/design-director.json', + agentId: 'design-director', + activeSessionId, + sessions: sessions.map((session) => ({ + ...session, + messageCount: messages.get(session.sessionId)?.length ?? 0, + })), + }); + const conversationResult = (sessionId: string) => ({ + path: + sessionId === legacySessionId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : `/tmp/authorized-game/.agent/conversations/agents/design-director/sessions/${sessionId}.jsonl`, + agentId: 'design-director', + sessionId, + messages: (messages.get(sessionId) ?? []).map((message, index) => ({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + agentId: 'design-director', + updatedAt: index + 1, + })), + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'list_game_creator_agent_sessions') { + return sessionResult(); + } + if (command === 'set_active_game_creator_agent_session') { + activeSessionId = String(args?.sessionId); + return sessionResult(); + } + if (command === 'create_game_creator_agent_session') { + activeSessionId = createdSessionId; + sessions.push({ + sessionId: createdSessionId, + title: '新会话', + createdAt: 3, + updatedAt: 3, + archivedAt: null, + messageCount: 0, + legacy: false, + }); + messages.set(createdSessionId, []); + return sessionResult(); + } + if (command === 'archive_game_creator_agent_session') { + const session = sessions.find( + (candidate) => candidate.sessionId === args?.sessionId, + ); + if (session) { + session.archivedAt = 4; + } + activeSessionId = legacySessionId; + return sessionResult(); + } + if (command === 'read_local_conversation') { + return conversationResult( + String(args?.sessionId ?? legacySessionId), + ); + } + if (command === 'append_local_conversation_message') { + const sessionId = String(args?.sessionId ?? legacySessionId); + const message = args?.message as { role: string; content: string }; + messages.get(sessionId)?.push(message); + return conversationResult(sessionId); + } + if (command === 'chat_with_game_creator_role_agent') { + return { replyText: '新会话回复' }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + + expect(await screen.findByText('角色规范历史')).not.toBeNull(); + expect(screen.queryByText('默认会话历史')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /默认会话/ })); + expect(await screen.findByText('默认会话历史')).not.toBeNull(); + expect(screen.queryByText('角色规范历史')).toBeNull(); + expect(invoke).toHaveBeenCalledWith( + 'set_active_game_creator_agent_session', + { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + sessionId: legacySessionId, + }, + ); + + fireEvent.click(screen.getByRole('button', { name: '新建 Agent 会话' })); + expect(await screen.findByText('暂无对话')).not.toBeNull(); + fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { + target: { value: '只属于新会话的问题' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + expect(await screen.findByText('新会话回复')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + sessionId: createdSessionId, + prompt: '只属于新会话的问题', + }); + + fireEvent.click( + screen.getByRole('button', { name: '归档当前 Agent 会话' }), + ); + expect(await screen.findByText('默认会话历史')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith( + 'archive_game_creator_agent_session', + { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + sessionId: createdSessionId, + }, + ); + fireEvent.click( + screen.getByRole('button', { name: /新会话\s+已归档/ }), + ); + expect(await screen.findByText('只属于新会话的问题')).not.toBeNull(); + expect(screen.getByLabelText('Agent 聊天内容')).toHaveProperty( + 'disabled', + true, + ); + fireEvent.click(screen.getByRole('button', { name: '刷新状态' })); + expect(await screen.findByText('只属于新会话的问题')).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenLastCalledWith( + 'read_game_creator_agent_runtime', + { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + sessionId: createdSessionId, + }, + ); + }); + }); + + it('does not downgrade a real Agent session catalog error to legacy writes', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'list_game_creator_agent_sessions') { + throw new Error('读取 Agent Session 目录失败:permission denied'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + + expect( + await screen.findAllByText( + 'Agent 会话列表读取失败:读取 Agent Session 目录失败:permission denied', + ), + ).toHaveLength(2); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_conversation', + expect.anything(), + ); + expect(screen.getByRole('button', { name: '新建 Agent 会话' })).toHaveProperty( + 'disabled', + true, + ); + }); + it('shows developer agent chat LLM configuration gaps before sending', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d51e24809..a2e4b0230 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4088,6 +4088,7 @@ - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `file.list`。Agent 可自行列出项目文件摘要或相对路径范围内的条目,先观察项目结构再决定是否读取具体文件;该工具必须受 `file.list` 项目权限策略保护,observation 只返回项目相对路径、类型和大小,不读取文件内容、不返回本机绝对路径。 - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `agent.delegate`。Agent 可把任务投递给另一个 Agent 的独立后台队列,复用目标 Agent 既有锁和 pending drain 语义;策略要求确认或拒绝时不得写目标 Agent 对话、不得启动目标任务,也不得写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 调整:Agent Runtime V1 新增 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 里的上一进程遗留 `running` 或 `pending` 任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`,并写 `agent.runtime.background_task.recovered` 审计记录。该能力只恢复本地 JSONL 队列到当前 App 进程,不是跨重启常驻 worker,也不承诺恢复已发出的上游 LLM 请求。 +- 2026-07-10 调整:每个 Agent 新增独立持久化 Session 管理。legacy `agent-session-` 继续读写 `.agent/conversations/agents/.jsonl`;新 Session 写 `.agent/conversations/agents//sessions/.jsonl`,`.agent/runtime/sessions/.json` 原子保存 Session catalog 和 active Session。开发单 Agent 聊天页支持列表、创建、切换、归档和归档历史只读查看;归档不删除消息,运行中、排队中、等待确认、取消中或 `needs-reconciliation` 的 Session 不允许改变 active/归档。聊天、流式回调、后台 run、任务历史、事件历史和 prompt 连续上下文按启动时 `sessionId` 归属并过滤,`conversation.read` 和 self `agent.run_status` 通过 runId 使用同一 Session;恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。Runtime 的 OS 锁、FIFO 队列和恢复屏障仍属于 Agent,同一 Agent 不因多个 Session 获得并行执行能力。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 - 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7ffe74b49..54e44a6f0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -321,6 +321,7 @@ game-project/ - `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。 - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 - 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。这里的 `` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/.json` 和 `.agent/runtime/events/.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentGoal`、`currentAction`、`waitingOn`、`nextStep`、`loopIteration`、`maxLoopIterations`、`toolActionBudget`、`plan`、`planSteps`、`activePlanStepIndex`、`observations`、`recentToolCalls`、`toolPolicy`、`allowedTools`、`lastResponse` 和 `error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask`、`currentGoal`、event detail、`lastResponse` 和 `agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 +- 2026-07-10 补充:每个 Agent 支持多个持久化 Session。旧 `agent-session-` 永久映射原 `.agent/conversations/agents/.jsonl`,不迁移、不复制、不删除;新 Session 写入 `.agent/conversations/agents//sessions/.jsonl`,目录索引和 active Session 写入 `.agent/runtime/sessions/.json`。开发单 Agent 聊天页提供创建、切换、归档和已归档只读查看;归档只更新元数据,不截断对话。直接聊天、流式回调和后台任务在启动时捕获 `agentId + sessionId`,Runtime task / event 继续使用每 Agent append-only JSONL,但列表、任务队列、连续上下文和最近对话按 Session 过滤;Runtime 内的 `conversation.read` 和 self `agent.run_status` 通过 runId 继续使用该 Session,恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。同一 Agent 仍共享一把 OS 锁并严格串行,不允许借 Session 绕过 pending、确认或 `needs-reconciliation` 屏障;不同 Agent 仍可并行。 - 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / event:active 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。 @@ -329,7 +330,7 @@ game-project/ - `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。 - `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run,`/agent-kill` 标记为 `killed`,`/agent-retry` 与 `/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`;状态 / 控制结果消息可一键填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,Agent 状态栏也可填入 output / activity / context bundle 的 `/read` 草稿。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 - 主窗口 Agent 状态栏的“继续”保留确认卡,“继续说明”只把 `/agent-resume ` 放入聊天输入框,方便用户补充说明后再走同一确认流。 -- v1 的 agent 状态列表和单 agent 对话都复用上述本地文件事实源:状态从 manifest / run trace 派生,单 agent 消息写对应 conversation JSONL;不引入 fork/archive 语义,不把 `pending` 包装成已经具备后台 claim / resume runner。 +- v1 的 agent 状态列表和单 agent 对话都复用上述本地文件事实源:状态从 manifest / run trace 派生,单 agent 消息写对应 Session conversation JSONL;Session 支持创建、切换和只改元数据的归档,不引入对话 fork,也不把 `pending` 包装成已经具备后台 claim / resume runner。 - `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。 - `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`memory/agents/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 - `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。