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 bcd259b04..2e77526e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -2646,6 +2646,48 @@ async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::Finished; } + if let Err(error) = append_local_conversation_message_for_session_at( + &root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: final_reply.clone(), + agent_id: None, + }, + ) { + let error = format!( + "后台任务 assistant 回复落盘失败:{}", + redact_agent_runtime_project_paths(&root, &error, 500) + ); + let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_for_session_at( + &root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + return AgentBackgroundTaskOutcome::Finished; + } + match finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &final_reply) { Ok(completed_runtime) => { runtime = completed_runtime; @@ -2688,16 +2730,6 @@ async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::Finished; } } - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: final_reply.clone(), - agent_id: None, - }, - ); let _ = append_agent_db_record( &root, serde_json::json!({ 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 37fbfcea6..1e93a34ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -4879,6 +4879,131 @@ fn background_task_does_not_execute_when_user_message_cannot_persist() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_task_fails_when_assistant_message_cannot_persist() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "后台回复对话一致性测试").expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("这条后台回复必须先持久化。")], + request_sender, + release_receiver, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证后台 assistant 回复落盘失败", + "assistant-conversation-write-failure-run", + ) + .expect("start background task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("background planning request in flight"); + + let session_id = + resolve_agent_conversation_session_id_at(&root, "design-director", None, false) + .expect("resolve agent session"); + let (conversation_path, _, _) = + conversation_file_path_for_session(&root, Some("design-director"), Some(&session_id)) + .expect("resolve conversation path"); + let mut conversation_permissions = fs::metadata(&conversation_path) + .expect("read conversation permissions") + .permissions(); + conversation_permissions.set_readonly(true); + fs::set_permissions(&conversation_path, conversation_permissions) + .expect("make conversation read only"); + release_sender + .send(()) + .expect("release background planning response"); + + let mut result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read assistant persistence runtime"); + for _ in 0..100 { + if matches!(result.state.status.as_str(), "idle" | "failed") { + break; + } + std::thread::sleep(Duration::from_millis(20)); + result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read assistant persistence runtime"); + } + + let mut conversation_permissions = fs::metadata(&conversation_path) + .expect("read final conversation permissions") + .permissions(); + conversation_permissions.set_readonly(false); + fs::set_permissions(&conversation_path, conversation_permissions) + .expect("restore conversation permissions"); + + assert_eq!(result.state.status, "failed"); + assert_eq!(result.state.phase, "failed"); + assert!(result + .state + .error + .as_deref() + .is_some_and(|error| error.contains("assistant 回复落盘失败"))); + assert!(result.recent_tasks.iter().any(|task| { + task.run_id == "assistant-conversation-write-failure-run" + && task.status == "failed" + && task.phase == "failed" + })); + assert!(!result.recent_tasks.iter().any(|task| { + task.run_id == "assistant-conversation-write-failure-run" && task.status == "completed" + })); + assert!(result + .recent_events + .iter() + .any(|event| event.event_type == "turn.failed")); + assert!(!result + .recent_events + .iter() + .any(|event| event.event_type == "turn.completed")); + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read failed assistant conversation"); + assert!(conversation + .messages + .iter() + .all(|message| message.role != "assistant")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + let audit_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + assert!(audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.failed") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + })); + assert!(!audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.completed") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + })); + assert!(!audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.completed") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + })); + + fs::remove_dir_all(root).ok(); +} + #[test] fn delegated_agent_receipt_redacts_terminal_credentials() { let root = unique_project_path(); @@ -11760,6 +11885,35 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies( assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.completed\"")); assert!(agent_db.contains("\"agentId\":\"art-director\"")); assert!(agent_db.contains("\"agentId\":\"design-director\"")); + let audit_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + for (agent_id, run_id) in [ + ("art-director", "art-background-run"), + ("design-director", "design-background-run"), + ] { + let assistant_message_index = audit_records + .iter() + .position(|record| { + record.get("recordType").and_then(Value::as_str) == Some("conversation.message") + && record.get("agentId").and_then(Value::as_str) == Some(agent_id) + && record.get("role").and_then(Value::as_str) == Some("assistant") + }) + .expect("assistant conversation audit"); + let runtime_completed_index = audit_records + .iter() + .position(|record| { + record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.completed") + && record.get("agentId").and_then(Value::as_str) == Some(agent_id) + && record.get("runId").and_then(Value::as_str) == Some(run_id) + }) + .expect("runtime completed audit"); + assert!( + assistant_message_index < runtime_completed_index, + "assistant conversation must persist before Runtime completes for {agent_id}" + ); + } assert!( game_creator_agent_runtime_task_lock_is_available(&root, "art-director") .expect("art runtime lock released") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 74b3236c4..b6e143972 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -109,6 +109,22 @@ type LauncherView = | 'news' | 'project-development'; type HomeAgentMode = 'game' | 'art' | 'doc'; +type AgentChatInteractionMode = 'run' | 'chat'; +type AgentChatReplyPhase = + | 'idle' + | 'saving-user' + | 'connecting' + | 'waiting-first-content' + | 'streaming' + | 'saving-reply'; + +type AgentChatPendingRuntimeRun = { + projectPath: string; + agentId: string; + sessionId: string | null; + runId: string; + messageCount: number; +}; type HomeAttachmentDraft = { id: string; @@ -752,6 +768,49 @@ function agentRuntimeStartStatus(result: AgentRuntimeResult) { : `已启动后台任务:${result.state.runId}`; } +function agentRuntimeStartedRunId( + result: AgentRuntimeResult, + requestedRunId: string, +) { + if ( + result.recentTasks?.some((task) => task.runId === requestedRunId) || + result.state.runId === requestedRunId + ) { + return requestedRunId; + } + return ( + result.taskQueue?.latestRunId ?? + result.state.taskQueue?.latestRunId ?? + result.state.runId ?? + requestedRunId + ); +} + +function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) { + return ( + ['completed', 'failed', 'cancelled'].includes(runtime.phase) || + ['completed', 'failed', 'cancelled', 'idle'].includes(runtime.status) + ); +} + +function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { + if (isAgentRuntimeTerminalState(runtime)) { + if (runtime.status === 'failed' || runtime.phase === 'failed') { + return 'Agent 运行失败,正在同步错误记录'; + } + if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { + return 'Agent 任务已取消,正在同步对话'; + } + return 'Agent 已完成,正在同步回复'; + } + if (runtime.status === 'pending' || runtime.phase === 'queued') { + return 'Agent 任务已排队,正在等待执行'; + } + const waitingOn = + runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); + return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行'; +} + function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { const summary = event.summary || event.detail || event.runId; const detail = @@ -2971,10 +3030,16 @@ export function WorkspaceLauncher({ useState(''); const [agentChatSessionStatus, setAgentChatSessionStatus] = useState(''); const [agentChatInput, setAgentChatInput] = useState(''); + const [agentChatInteractionMode, setAgentChatInteractionMode] = + useState('run'); + const [agentChatReplyPhase, setAgentChatReplyPhase] = + useState('idle'); const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent'); const [agentChatBusy, setAgentChatBusy] = useState(false); const agentChatMessagesRef = useRef(null); const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false); + const [agentChatPendingRuntimeRun, setAgentChatPendingRuntimeRunState] = + useState(null); const [agentChatLlmConfigStatus, setAgentChatLlmConfigStatus] = useState(null); const [agentChatLlmStatus, setAgentChatLlmStatus] = @@ -2996,13 +3061,106 @@ export function WorkspaceLauncher({ agentChatSelectedSessionIdRef.current = agentChatSelectedSessionId; const agentChatActiveSessionIdRef = useRef(agentChatActiveSessionId); agentChatActiveSessionIdRef.current = agentChatActiveSessionId; + const agentChatPendingRuntimeRunRef = + useRef(null); + const agentChatRuntimeSyncingRunIdsRef = useRef(new Set()); + const agentChatRuntimeSyncConversationRef = useRef< + | (( + invoke: TauriInvoke, + pendingRun: AgentChatPendingRuntimeRun, + ) => Promise) + | null + >(null); + + function setAgentChatPendingRuntimeRun( + pendingRun: AgentChatPendingRuntimeRun | null, + ) { + agentChatPendingRuntimeRunRef.current = pendingRun; + setAgentChatPendingRuntimeRunState(pendingRun); + } + + async function syncAgentChatConversationAfterRuntime( + invoke: TauriInvoke, + pendingRun: AgentChatPendingRuntimeRun, + ) { + let latestResult: LocalConversationResult | null = null; + for (let attempt = 0; attempt < 4; attempt += 1) { + if ( + agentChatProjectPathRef.current.trim() !== pendingRun.projectPath || + agentChatSelectedAgentIdRef.current !== pendingRun.agentId || + (pendingRun.sessionId !== null && + agentChatSelectedSessionIdRef.current !== pendingRun.sessionId) + ) { + return; + } + try { + latestResult = await invoke( + 'read_local_conversation', + { + projectPath: pendingRun.projectPath, + agentId: pendingRun.agentId, + ...(pendingRun.sessionId + ? { sessionId: pendingRun.sessionId } + : {}), + }, + ); + } catch (error) { + if (attempt === 3) { + setAgentChatStatus( + `Agent 已结束,但同步对话失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + break; + } + } + const newMessages = + latestResult?.messages.slice(pendingRun.messageCount) ?? []; + if ( + newMessages.some((message) => message.role === 'assistant') || + attempt === 3 + ) { + break; + } + await new Promise((resolve) => { + window.setTimeout(resolve, 40); + }); + } + if ( + latestResult && + agentChatProjectPathRef.current.trim() === pendingRun.projectPath && + agentChatSelectedAgentIdRef.current === pendingRun.agentId && + (pendingRun.sessionId === null || + agentChatSelectedSessionIdRef.current === pendingRun.sessionId) + ) { + setAgentChatMessages(latestResult.messages); + setAgentChatConversationPath(latestResult.path); + updateAgentChatSessionMessageCount( + pendingRun.sessionId, + latestResult.messages.length, + ); + setAgentChatStatus( + `已同步 ${latestResult.messages.length} 条:${latestResult.path}`, + ); + } + if (agentChatPendingRuntimeRunRef.current?.runId === pendingRun.runId) { + setAgentChatPendingRuntimeRun(null); + } + } + agentChatRuntimeSyncConversationRef.current = + syncAgentChatConversationAfterRuntime; useLayoutEffect(() => { const messageList = agentChatMessagesRef.current; if (messageList) { messageList.scrollTop = messageList.scrollHeight; } - }, [agentChatBusy, agentChatMessages, agentChatStatus]); + }, [ + agentChatMessages, + agentChatPendingRuntimeRun, + agentChatReplyPhase, + agentChatStatus, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -3039,7 +3197,8 @@ export function WorkspaceLauncher({ useEffect(() => { const listen = window.__TAURI__?.event?.listen; - if (!listen) { + const invoke = resolveTauriInvoke(); + if (!listen || !invoke) { return; } let cleanup: (() => void) | null = null; @@ -3074,6 +3233,32 @@ export function WorkspaceLauncher({ agentRuntimeStateFromResult(payload.runtime, current), ); setAgentChatRuntimeError(''); + const pendingRun = agentChatPendingRuntimeRunRef.current; + if ( + !pendingRun || + pendingRun.projectPath !== payload.projectPath || + pendingRun.agentId !== payload.agentId || + pendingRun.runId !== payload.runId || + (pendingRun.sessionId !== null && + pendingRun.sessionId !== payload.runtime.state.sessionId) + ) { + return; + } + const runtimeState = agentRuntimeStateFromResult(payload.runtime); + setAgentChatStatus(agentRuntimeConversationStatus(runtimeState)); + if ( + isAgentRuntimeTerminalState(runtimeState) && + !agentChatRuntimeSyncingRunIdsRef.current.has(pendingRun.runId) + ) { + const syncConversation = agentChatRuntimeSyncConversationRef.current; + if (!syncConversation) { + return; + } + agentChatRuntimeSyncingRunIdsRef.current.add(pendingRun.runId); + void syncConversation(invoke, pendingRun).finally(() => { + agentChatRuntimeSyncingRunIdsRef.current.delete(pendingRun.runId); + }); + } }, ) .then((unlisten) => { @@ -3702,6 +3887,8 @@ export function WorkspaceLauncher({ } function resetAgentChatSessionView() { + setAgentChatReplyPhase('idle'); + setAgentChatPendingRuntimeRun(null); setAgentChatSessions([]); setAgentChatSelectedSessionId(null); setAgentChatActiveSessionId(null); @@ -3898,6 +4085,16 @@ export function WorkspaceLauncher({ if (!projectPathForChat || !agent) { return; } + const pendingRun = agentChatPendingRuntimeRunRef.current; + if ( + pendingRun && + (pendingRun.projectPath !== projectPathForChat || + pendingRun.agentId !== agent.id || + (requestedSessionId !== undefined && + pendingRun.sessionId !== requestedSessionId)) + ) { + setAgentChatPendingRuntimeRun(null); + } const invoke = resolveTauriInvoke(); if (!invoke) { setAgentChatStatus('需要在 Tauri App 内运行'); @@ -4254,6 +4451,7 @@ export function WorkspaceLauncher({ const saveVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = saveVersion; setAgentChatBusy(true); + setAgentChatReplyPhase('saving-user'); setAgentChatInput(''); setAgentChatStatus('正在保存用户消息'); let savedUserResult: LocalConversationResult | null = null; @@ -4288,6 +4486,7 @@ export function WorkspaceLauncher({ sessionIdForChat, savedUserMessages.length, ); + setAgentChatReplyPhase('connecting'); setAgentChatStatus('正在连接 Agent LLM'); const streamRunId = createAgentChatRunId('launcher-agent-chat'); const listen = window.__TAURI__?.event?.listen; @@ -4315,6 +4514,7 @@ export function WorkspaceLauncher({ setAgentChatRuntimeError(''); } if (payload.status === 'started') { + setAgentChatReplyPhase('waiting-first-content'); setAgentChatStatus( payload.runtimeSummary ? `已连接 Agent LLM,${payload.runtimeSummary}` @@ -4323,6 +4523,7 @@ export function WorkspaceLauncher({ return; } if (payload.status === 'delta') { + setAgentChatReplyPhase('streaming'); const draftText = payload.accumulatedText || payload.deltaText; if (draftText) { pendingStreamDraftText = draftText; @@ -4357,6 +4558,7 @@ export function WorkspaceLauncher({ window.cancelAnimationFrame(streamFrameId); streamFrameId = null; } + setAgentChatReplyPhase('saving-reply'); setAgentChatStatus( payload.runtimeSummary ?? 'Agent 回复完成,正在保存', ); @@ -4367,6 +4569,7 @@ export function WorkspaceLauncher({ window.cancelAnimationFrame(streamFrameId); streamFrameId = null; } + setAgentChatReplyPhase('saving-reply'); setAgentChatStatus( payload.runtimeSummary ?? 'Agent 流式回复失败,正在记录错误', ); @@ -4390,6 +4593,7 @@ export function WorkspaceLauncher({ ? '实时状态不可用,正在使用普通回复模式' : '正在等待 Agent LLM 回复', ); + setAgentChatReplyPhase('waiting-first-content'); let reply: GameCreatorChatAgentReply; if (streamListenReady) { try { @@ -4464,6 +4668,7 @@ export function WorkspaceLauncher({ window.cancelAnimationFrame(streamFrameId); streamFrameId = null; } + setAgentChatReplyPhase('saving-reply'); setAgentChatStatus('正在保存 Agent 回复'); setAgentChatMessages([ ...savedUserMessages, @@ -4552,6 +4757,7 @@ export function WorkspaceLauncher({ window.cancelAnimationFrame(streamFrameId); } if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatReplyPhase('idle'); setAgentChatBusy(false); } } @@ -4588,8 +4794,17 @@ export function WorkspaceLauncher({ } const saveVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = saveVersion; + const requestedRunId = createAgentChatRunId('launcher-agent-task'); + let pendingRunId = requestedRunId; setAgentChatBackgroundBusy(true); setAgentChatInput(''); + setAgentChatPendingRuntimeRun({ + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: sessionIdForTask, + runId: requestedRunId, + messageCount: agentChatMessages.length, + }); setAgentChatStatus('正在启动 Agent 后台任务'); try { const runtime = await invoke( @@ -4598,7 +4813,7 @@ export function WorkspaceLauncher({ projectPath: projectPathForChat, agentId: agent.id, task: content, - runId: createAgentChatRunId('launcher-agent-task'), + runId: requestedRunId, ...agentChatSessionInvokeArgs(sessionIdForTask), }, ); @@ -4606,6 +4821,14 @@ export function WorkspaceLauncher({ return; } const runtimeState = agentRuntimeStateFromResult(runtime); + pendingRunId = agentRuntimeStartedRunId(runtime, requestedRunId); + setAgentChatPendingRuntimeRun({ + projectPath: projectPathForChat, + agentId: agent.id, + sessionId: sessionIdForTask, + runId: pendingRunId, + messageCount: agentChatMessages.length, + }); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); setAgentChatRuntimeError(''); @@ -4621,11 +4844,25 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(conversation.messages); + if (agentChatPendingRuntimeRunRef.current?.runId === pendingRunId) { + setAgentChatPendingRuntimeRun({ + ...agentChatPendingRuntimeRunRef.current, + messageCount: conversation.messages.length, + }); + } + setAgentChatConversationPath(conversation.path); + updateAgentChatSessionMessageCount( + sessionIdForTask, + conversation.messages.length, + ); setAgentChatStatus(agentRuntimeStartStatus(runtime)); } catch (error) { if (agentChatLoadVersionRef.current !== saveVersion) { return; } + if (agentChatPendingRuntimeRunRef.current?.runId === pendingRunId) { + setAgentChatPendingRuntimeRun(null); + } setAgentChatInput(content); setAgentChatStatus( error instanceof Error ? error.message : String(error), @@ -5013,6 +5250,16 @@ export function WorkspaceLauncher({ const currentAgentChatLlmWarning = getCurrentAgentChatLlmWarning( currentAgentChatAgent, ); + const currentAgentChatWaiting = + agentChatReplyPhase !== 'idle' || agentChatPendingRuntimeRun !== null; + const currentAgentChatWaitingStatus = + agentChatPendingRuntimeRun && + agentChatRuntime?.runId === agentChatPendingRuntimeRun.runId + ? agentRuntimeConversationStatus(agentChatRuntime) + : agentChatStatus; + const currentAgentChatWaitingDetail = agentChatPendingRuntimeRun + ? '任务仍在运行,状态和最终回复会自动更新' + : '请求仍在进行中,收到回复后会立即显示'; const currentHelpTitle = launcherView === 'guide' ? '使用指南' @@ -5822,7 +6069,7 @@ export function WorkspaceLauncher({ ) : (

暂无对话

)} - {agentChatBusy ? ( + {currentAgentChatWaiting ? (
) : null}
{ + if (agentChatInteractionMode === 'run') { + event.preventDefault(); + void handleAgentChatStartBackgroundTask(); + return; + } + void handleAgentChatSubmit(event); + }} > +
+ + +
发送 -
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 275cf04b3..217709382 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1150,7 +1150,7 @@ textarea { .launcher-agent-chat-main { display: grid; - grid-template-rows: repeat(6, auto); + grid-template-rows: auto auto auto auto clamp(260px, 40vh, 380px) auto; align-content: start; width: 100%; overflow: visible; @@ -1262,15 +1262,13 @@ textarea { display: grid; align-content: start; gap: 10px; - height: clamp(260px, 40vh, 380px); - max-height: 380px; + height: 100%; + max-height: none; min-height: 0; padding: 14px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - overscroll-behavior: contain; - scrollbar-gutter: stable; } .launcher-agent-chat-messages .message { @@ -1336,7 +1334,8 @@ textarea { .launcher-agent-chat-composer { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; gap: 8px; padding: 12px; border-top: 1px solid #e5e7eb; @@ -1361,6 +1360,31 @@ textarea { color: #fff; } +.launcher-agent-chat-mode { + display: grid; + grid-template-columns: repeat(2, auto); + gap: 2px; + padding: 2px; + border: 1px solid #d8dde5; + border-radius: 8px; + background: #f3f4f6; +} + +.launcher-agent-chat-mode button { + height: 30px; + padding: 0 9px; + border-radius: 6px; + background: transparent; + color: #6b7280; + font-size: 12px; +} + +.launcher-agent-chat-mode button.is-active { + background: #fff; + box-shadow: 0 1px 2px rgb(15 23 42 / 10%); + color: #111827; +} + .launcher-agent-runtime-stack { display: grid; gap: 8px; @@ -1837,6 +1861,15 @@ textarea { min-height: 620px; } + .launcher-agent-chat-composer { + grid-template-columns: minmax(0, 1fr) auto; + } + + .launcher-agent-chat-mode { + grid-column: 1 / -1; + justify-self: start; + } + .launcher-agent-session-bar { grid-template-columns: auto minmax(0, 1fr); } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 3a45e58e1..661c12404 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -64,6 +64,14 @@ function renderLauncherAgentChatAt(path: string) { renderLauncherAt(path, 'agent-chat'); } +function selectDeveloperAgentChatMode(mode: 'run' | 'chat') { + fireEvent.click( + screen.getByRole('button', { + name: mode === 'run' ? '执行' : '聊天', + }), + ); +} + function renderLauncherProjectsAt(path: string) { renderLauncherAt(path); fireEvent.click(screen.getByRole('button', { name: '项目组' })); @@ -1012,7 +1020,9 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error('LLM SSE 响应缺少 choices[0]'); } if (command === 'chat_with_game_creator_role_agent') { - throw new Error('completed stream must not fall back to another request'); + throw new Error( + 'completed stream must not fall back to another request', + ); } if (command === 'append_local_conversation_message') { const message = args?.message as { @@ -1068,6 +1078,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '请单独评估这个角色设定流程' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect( @@ -1096,9 +1107,7 @@ describe('AI 游戏创作 App 界面边界', () => { 'chat_with_game_creator_role_agent', expect.anything(), ); - expect( - screen.queryByText(/已保存用户消息;Agent 回复失败/), - ).toBeNull(); + expect(screen.queryByText(/已保存用户消息;Agent 回复失败/)).toBeNull(); expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_role_agent_stream', expect.objectContaining({ @@ -1170,6 +1179,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '流式失败时继续回答' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('普通回复补位成功。')).not.toBeNull(); @@ -1288,6 +1298,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '监听失败后继续回复' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect( @@ -1584,6 +1595,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '只属于新会话的问题' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('新会话回复')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { @@ -1928,6 +1940,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '请流式回答' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); const waitingForFirstDelta = await within( @@ -2000,7 +2013,7 @@ describe('AI 游戏创作 App 界面边界', () => { ]); }); - it('starts a developer agent background task without blocking on chat reply', async () => { + it('runs developer agent work by default and syncs the terminal reply', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant'; content: string; @@ -2203,6 +2216,7 @@ describe('AI 游戏创作 App 界面边界', () => { updatedAt: 4006, }, ]; + let startedRunId = runningRuntimeState.runId; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'check_game_creator_llm_config') { @@ -2256,6 +2270,7 @@ describe('AI 游戏创作 App 界面边界', () => { } if (command === 'start_game_creator_agent_runtime_task') { const runId = String(args?.runId ?? runningRuntimeState.runId); + startedRunId = runId; persistedMessages.push({ role: 'user', content: String(args?.task ?? ''), @@ -2310,11 +2325,18 @@ describe('AI 游戏创作 App 界面边界', () => { }); fireEvent.click(screen.getByRole('button', { name: '读取历史' })); expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); + expect( + screen.getByRole('button', { name: '执行' }).getAttribute('aria-pressed'), + ).toBe('true'); + expect( + screen.getByRole('button', { name: '聊天' }).getAttribute('aria-pressed'), + ).toBe('false'); + expect(screen.queryByRole('button', { name: '后台运行' })).toBeNull(); fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '后台整理角色规范' }, }); - fireEvent.click(screen.getByRole('button', { name: '后台运行' })); + fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('后台整理角色规范')).not.toBeNull(); expect(await screen.findByText('running / action')).not.toBeNull(); @@ -2343,6 +2365,10 @@ describe('AI 游戏创作 App 界面边界', () => { }) as HTMLButtonElement ).disabled, ).toBe(true); + expect( + within(screen.getByLabelText('Agent 聊天记录')).getByRole('status') + .textContent, + ).toContain('等待工具观察结果'); const runtimePanel = screen.getByLabelText('Agent Runtime 状态'); const collapseRuntimeButton = within(runtimePanel).getByRole('button', { name: '折叠 Runtime 详情', @@ -2482,11 +2508,11 @@ describe('AI 游戏创作 App 界面边界', () => { payload: { projectPath: '/tmp/authorized-game', agentId: 'design-director', - runId: 'launcher-agent-task-test', + runId: startedRunId, status: 'cancelling', phase: 'cancelling', runtime: { - state: cancellingRuntimeState, + state: { ...cancellingRuntimeState, runId: startedRunId }, sessionPath: '/tmp/authorized-game/.agent/runtime/agents/design-director.json', eventPath: @@ -2519,23 +2545,31 @@ describe('AI 游戏创作 App 界面边界', () => { ).disabled, ).toBe(true); + persistedMessages.push({ + role: 'assistant', + content: '角色规范已整理。', + agentId: null, + }); await act(async () => { runtimeUpdateHandler?.({ payload: { projectPath: '/tmp/authorized-game', agentId: 'design-director', - runId: 'launcher-agent-task-test', + runId: startedRunId, status: 'idle', phase: 'completed', runtime: { - state: completedRuntimeState, + state: { ...completedRuntimeState, runId: startedRunId }, sessionPath: '/tmp/authorized-game/.agent/runtime/agents/design-director.json', eventPath: '/tmp/authorized-game/.agent/runtime/events/design-director.jsonl', taskPath: '/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl', - taskQueue: completedRuntimeState.taskQueue, + taskQueue: { + ...completedRuntimeState.taskQueue, + latestRunId: startedRunId, + }, recentEvents: [ ...runningRuntimeEvents, { @@ -2543,7 +2577,7 @@ describe('AI 游戏创作 App 界面边界', () => { agentId: 'design-director', taskId: 'design-director', sessionId: 'agent-session-design-director', - runId: 'launcher-agent-task-test', + runId: startedRunId, source: 'agent-background-task', eventType: 'turn.completed', status: 'idle', @@ -2553,19 +2587,276 @@ describe('AI 游戏创作 App 界面边界', () => { updatedAt: 4010, }, ], - recentTasks: [completedRuntimeTask], + recentTasks: [{ ...completedRuntimeTask, runId: startedRunId }], }, }, }); }); expect(await screen.findByText('idle / completed')).not.toBeNull(); + expect(await screen.findByText('角色规范已整理。')).not.toBeNull(); expect(screen.getByText('等待:开发者下一轮输入')).not.toBeNull(); expect( screen.getByText( 'turn.completed · idle / completed · Agent Runtime 完成本轮处理。 · 角色规范已整理。', ), ).not.toBeNull(); + expect( + within(screen.getByLabelText('Agent 聊天记录')).queryByRole('status'), + ).toBeNull(); + expect(persistedMessages).toEqual([ + { + role: 'user', + content: '后台整理角色规范', + agentId: null, + }, + { + role: 'assistant', + content: '角色规范已整理。', + agentId: null, + }, + ]); + }); + + it('does not sync an old runtime reply into another Agent session', async () => { + const activeSessionId = 'agent-session-design-active'; + const archivedSessionId = 'agent-session-design-archived'; + const messagesBySession: Record< + string, + Array<{ + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }> + > = { + [activeSessionId]: [], + [archivedSessionId]: [ + { + role: 'assistant', + content: '这是归档会话内容。', + agentId: null, + }, + ], + }; + const runtimeState = ( + sessionId: string, + runId: string, + status: string, + ) => ({ + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'design-director', + taskId: 'design-director', + sessionId, + runId, + source: 'agent-background-task', + status, + phase: status === 'running' ? 'planning' : 'completed', + currentTask: '整理当前会话', + currentGoal: '整理当前会话', + currentAction: + status === 'running' ? '生成 Agent 工具计划' : '等待下一轮输入', + waitingOn: + status === 'running' ? 'Agent 输出计划或回复' : '开发者下一轮输入', + plan: [], + observations: [], + allowedTools: [], + lastResponse: status === 'running' ? null : '已完成', + error: null, + updatedAt: 6000, + }); + let startedRunId = ''; + let runtimeUpdateHandler: + | ((event: { payload: Record }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'check_game_creator_llm_config') { + return { + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + agents: [ + { + agentId: 'design-director', + label: '拆解创作方向', + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + }, + ], + }; + } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'list_game_creator_agent_sessions') { + return { + path: '/tmp/authorized-game/.agent/runtime/sessions/design-director.json', + agentId: 'design-director', + activeSessionId, + sessions: [ + { + sessionId: activeSessionId, + title: '活动会话', + createdAt: 1, + updatedAt: 2, + archivedAt: null, + messageCount: messagesBySession[activeSessionId].length, + legacy: false, + }, + { + sessionId: archivedSessionId, + title: '归档会话', + createdAt: 1, + updatedAt: 2, + archivedAt: 3, + messageCount: messagesBySession[archivedSessionId].length, + legacy: false, + }, + ], + }; + } + if (command === 'read_local_conversation') { + const sessionId = String(args?.sessionId ?? activeSessionId); + return { + path: `/tmp/authorized-game/.agent/conversations/agents/design-director/sessions/${sessionId}.jsonl`, + agentId: 'design-director', + messages: messagesBySession[sessionId].map((message, index) => ({ + schemaVersion: '1', + ...message, + updatedAt: 6100 + index, + })), + }; + } + if (command === 'read_game_creator_agent_runtime') { + const sessionId = String(args?.sessionId ?? activeSessionId); + return { + state: runtimeState(sessionId, `idle-${sessionId}`, 'idle'), + sessionPath: `/tmp/authorized-game/.agent/runtime/agents/design-director.json`, + eventPath: `/tmp/authorized-game/.agent/runtime/events/design-director.jsonl`, + recentEvents: [], + recentTasks: [], + }; + } + if (command === 'start_game_creator_agent_runtime_task') { + startedRunId = String(args?.runId ?? 'runtime-active'); + messagesBySession[activeSessionId].push({ + role: 'user', + content: String(args?.task ?? ''), + agentId: null, + }); + return { + state: runtimeState(activeSessionId, startedRunId, 'running'), + sessionPath: `/tmp/authorized-game/.agent/runtime/agents/design-director.json`, + eventPath: `/tmp/authorized-game/.agent/runtime/events/design-director.jsonl`, + recentEvents: [], + recentTasks: [ + { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'design-director', + taskId: 'design-director', + sessionId: activeSessionId, + runId: startedRunId, + source: 'agent-background-task', + task: String(args?.task ?? ''), + status: 'running', + phase: 'planning', + currentAction: '生成 Agent 工具计划', + error: null, + updatedAt: 6200, + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { + listen: vi.fn( + async ( + eventName: string, + handler: (event: { payload: Record }) => void, + ) => { + if (eventName === 'game-creator-agent-runtime-update') { + runtimeUpdateHandler = handler; + } + return () => {}; + }, + ), + }, + }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + expect( + await screen.findByRole('button', { name: /活动会话/ }), + ).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { + target: { value: '只属于活动会话的任务' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + expect(await screen.findByText('只属于活动会话的任务')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /归档会话/ })); + expect(await screen.findByText('这是归档会话内容。')).not.toBeNull(); + expect( + within(screen.getByLabelText('Agent 聊天记录')).queryByRole('status'), + ).toBeNull(); + const activeReadsBeforeTerminal = invoke.mock.calls.filter( + ([command, args]) => + command === 'read_local_conversation' && + (args as Record | undefined)?.sessionId === + activeSessionId, + ).length; + + messagesBySession[activeSessionId].push({ + role: 'assistant', + content: '这条回复不能进入归档会话。', + agentId: null, + }); + await act(async () => { + runtimeUpdateHandler?.({ + payload: { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: startedRunId, + status: 'idle', + phase: 'completed', + runtime: { + state: runtimeState(activeSessionId, startedRunId, 'idle'), + sessionPath: `/tmp/authorized-game/.agent/runtime/agents/design-director.json`, + eventPath: `/tmp/authorized-game/.agent/runtime/events/design-director.jsonl`, + recentEvents: [], + recentTasks: [], + }, + }, + }); + }); + + expect(screen.getByText('这是归档会话内容。')).not.toBeNull(); + expect(screen.queryByText('这条回复不能进入归档会话。')).toBeNull(); + expect( + invoke.mock.calls.filter( + ([command, args]) => + command === 'read_local_conversation' && + (args as Record | undefined)?.sessionId === + activeSessionId, + ).length, + ).toBe(activeReadsBeforeTerminal); }); it('confirms or rejects the exact pending tool action from the developer agent window', async () => { @@ -3026,13 +3317,13 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '排队整理第二个需求' }, }); - fireEvent.click(screen.getByRole('button', { name: '后台运行' })); + fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('正在处理上一条任务')).not.toBeNull(); expect(screen.getByText('Loop:1/3 · 工具预算 3')).not.toBeNull(); expect( - screen.getByText(/已加入后台队列:launcher-agent-task-/), - ).not.toBeNull(); + screen.getAllByText(/已加入后台队列:launcher-agent-task-/).length, + ).toBeGreaterThan(0); expect( screen.getByText(/pending \/ queued · 排队整理第二个需求/), ).not.toBeNull(); @@ -3174,6 +3465,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { target: { value: '请真实回复,不要只记录' }, }); + selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('请真实回复,不要只记录')).not.toBeNull(); @@ -16255,6 +16547,12 @@ describe('AI 游戏创作 App 界面边界', () => { expect(styles).toMatch(/\.message\s*\{[^}]*white-space:\s*pre-wrap/s); expect(styles).toMatch(/\.message\s*\{[^}]*overflow-wrap:\s*anywhere/s); + expect(styles).toMatch( + /\.launcher-agent-chat-main\s*\{[^}]*grid-template-rows:[^;]*clamp\(260px,\s*40vh,\s*380px\)/s, + ); + expect(styles).toMatch( + /\.launcher-agent-chat-messages\s*\{[^}]*overflow-y:\s*auto/s, + ); }); it('shows developer panels only in dev mode', () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3fd3be880..937120b0b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4125,6 +4125,7 @@ - 2026-06-24 调整:普通用户通过聊天输入 `/canvas 画板项目ID` 触发待确认 `canvas.project_open`,只打开本机 Genarrative 编辑器 `/editor/canvas?projectid=...`;不得把它扩展成远程站点或任意 URL 打开能力。 - 2026-06-24 调整:普通用户通过聊天输入 `/import-canvas-asset 本地路径 画板项目ID 资源ID|object:资产对象ID [kind] [mediaType]` 触发待确认 `canvas.asset_import`,只登记项目目录内已有文件为 `canvas` 来源资产;只有 `assetObjectId` 时使用 `object:` 前缀,不伪造 resourceId;画板导出包回流使用 `/import-canvas-export /绝对/画板素材.zip 画板项目ID`。 - 验证方式:`npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run test -- packages/shared/src/contracts/gameCreationApp.test.ts`、`cargo test -p shared-contracts game_creation_app --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-30 唯一码和私有码按用户限兑一次 - 背景:运营私有码按内部 user_id 指定用户后,指定用户仍可能无法兑换;排查 release `SEEDUSERLUO0630` 时确认兑换校验把私有码当成全局次数上限,而不是每个允许用户各自限兑一次。 @@ -4143,6 +4144,7 @@ ## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界 +- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。 - 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。 - 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。 - 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e0ec89f54..4084abe8a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -53,6 +53,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 - 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。 - 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空 `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 +- 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件;对话写入失败时本轮进入 failed,不得产生 completed 记录。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 - 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent`、`.git`、`node_modules`、`dist`、`build`、`target`、`.next`、`coverage` 和 `.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。 - 2026-07-11 补充:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`,其中 script 只允许项目根 `package.json` 中的 `check / typecheck / test / lint / build`,expectedCommand 必须与执行时重新读取的脚本正文完全一致,timeoutSeconds 为 1-300;当前执行器只支持 npm,其他 packageManager 或锁文件明确失败。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用 `--ignore-scripts`、空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;loop 耗尽仍未形成新通过结果时保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。