diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 41cd9b403..ec14b50ad 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -5,6 +5,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import ts from 'typescript'; + import { appIdentifier, defaultRealSwarmTestTask, @@ -80,7 +82,10 @@ const appSource = [ readSourceTree(new URL('../src/', import.meta.url), '.ts'), readSourceTree(new URL('../src/', import.meta.url), '.tsx'), ].join('\n'); -const appInvokeSource = appSource; +const appInvokeSources = readSourceFiles( + new URL('../src/', import.meta.url), + new Set(['.ts', '.tsx']), +); const appEntrypointSource = fs.readFileSync( new URL('../src/main.tsx', import.meta.url), 'utf8', @@ -120,26 +125,10 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ - 'archive_failed_local_project_resource_edit', - 'archive_failed_local_project_asset_canvas_generation', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', - 'commit_local_project_asset', - 'commit_local_project_asset_canvas_candidate', - 'confirm_local_project_asset_canvas_generation_service_identity', - 'create_local_project_asset_canvas_draft', - 'discard_local_project_asset_canvas_draft', - 'generate_local_project_asset_canvas_image', - 'import_local_project_asset_canvas_images', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', - 'read_local_project_asset_canvas_draft', - 'read_local_project_asset_canvas_media', - 'recover_local_project_asset_canvas_transactions', - 'recover_local_project_asset_canvas_generations', - 'stage_local_project_asset_canvas_image', - 'store_local_project_asset_canvas_media', - 'update_local_project_asset_canvas_draft', ]; const sourceExtensions = new Set([ '.json', @@ -189,6 +178,23 @@ function readSourceTree(path, extension) { return fs.readFileSync(path, 'utf8'); } +function readSourceFiles(path, extensions) { + const stat = fs.statSync(path); + if (stat.isDirectory()) { + return fs + .readdirSync(path, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => + readSourceFiles( + new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path), + extensions, + ), + ); + } + if (!extensions.has(pathnameExtension(path.pathname))) return []; + return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }]; +} + function pathnameExtension(pathname) { const index = pathname.lastIndexOf('.'); return index === -1 ? '' : pathname.slice(index); @@ -320,13 +326,94 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) { } } -function parseAppInvokeCommandNames(source) { - return Array.from( - source.matchAll( - /(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g, - ), - ([, command]) => command, +const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024; +const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024; +const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024; +const APP_INVOKE_COMMAND_MAX_LENGTH = 128; +const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024; +const APP_INVOKE_BARE_CALL_NAMES = new Set([ + 'invoke', + 'directInvoke', + 'invokeInput', + 'invokeAuthenticatedInput', +]); + +function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') { + const sourceByteLength = Buffer.byteLength(source, 'utf8'); + if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) { + throw new Error( + `AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`, + ); + } + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); + const parseDiagnostic = sourceFile.parseDiagnostics[0]; + if (parseDiagnostic !== undefined) { + throw new Error( + `AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`, + ); + } + const commands = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const isBareCall = + ts.isIdentifier(expression) && + APP_INVOKE_BARE_CALL_NAMES.has(expression.text); + const isObjectInvoke = + ts.isPropertyAccessExpression(expression) && + expression.name.text === 'invoke'; + const commandArgument = node.arguments[0]; + if ( + (isBareCall || isObjectInvoke) && + commandArgument !== undefined && + ts.isStringLiteral(commandArgument) && + commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH && + /^[a-z0-9_]+$/u.test(commandArgument.text) + ) { + commands.push(commandArgument.text); + if (commands.length > APP_INVOKE_CALL_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`, + ); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return commands; +} + +function parseAppInvokeSourceFiles(files) { + if (files.length > APP_INVOKE_FILE_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`, + ); + } + const totalLength = files.reduce( + (length, file) => length + Buffer.byteLength(file.source, 'utf8'), + 0, + ); + if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) { + throw new Error( + `AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`, + ); + } + const commands = files.flatMap(({ fileName, source }) => + parseAppInvokeCommandNames(source, fileName), + ); + if (commands.length > APP_INVOKE_CALL_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`, + ); + } + return commands; } function parseTauriHandlerCommandNames(source) { @@ -357,6 +444,99 @@ function assertCommandNamesSubset(label, leftNames, rightNames) { } } +function assertCommandNamesDisjoint(label, leftNames, rightNames) { + const right = new Set(rightNames); + const overlapping = Array.from(new Set(leftNames)) + .filter((name) => right.has(name)) + .sort((left, rightName) => left.localeCompare(rightName)); + if (overlapping.length > 0) { + throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`); + } +} + +function runAppInvokeParserRegressionChecks() { + assert.deepEqual( + parseAppInvokeCommandNames(` + invoke('direct_command', {}); + directInvoke('generic_direct_command', {}); + invokeInput < Result > ('input_wrapper_command', {}); + invokeAuthenticatedInput( + 'authenticated_input_wrapper_command', + {}, + ); + input.invoke('object_field_command', {}); + `), + [ + 'direct_command', + 'generic_direct_command', + 'input_wrapper_command', + 'authenticated_input_wrapper_command', + 'object_field_command', + ], + ); + + assert.deepEqual( + parseAppInvokeCommandNames(` + // invoke('line_comment_decoy') + /* invokeInput('block_comment_decoy') */ + const quoted = "directInvoke('string_decoy')"; + const template = \`input.invoke('template_decoy')\`; + const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u; + invokeCommand('unrelated_name'); + myinvoke('unrelated_suffix'); + invoke(dynamicCommand, {}); + invoke('UPPERCASE_COMMAND', {}); + `), + [], + ); + + assert.throws( + () => parseAppInvokeCommandNames("invoke('malformed_generic', {"), + /source cannot be parsed/u, + ); + + assert.deepEqual( + parseAppInvokeCommandNames( + `invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`, + ), + [], + ); + assert.throws( + () => + parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)), + /source exceeds/u, + ); + + const wrapperInvocations = parseAppInvokeCommandNames( + "invokeInput('wrapper_reachability_command', {})", + ); + assert.doesNotThrow(() => + assertCommandNamesSubset( + 'App invoke parser reachability fixture', + ['wrapper_reachability_command'], + wrapperInvocations, + ), + ); + assert.throws( + () => + assertCommandNamesDisjoint( + 'App invoke parser false allowlist fixture', + wrapperInvocations, + ['wrapper_reachability_command'], + ), + /overlapping commands: wrapper_reachability_command/u, + ); + assert.throws( + () => + assertCommandNamesSubset( + 'App invoke parser removed wrapper fixture', + ['wrapper_reachability_command'], + parseAppInvokeCommandNames('const wrapperWasRemoved = true;'), + ), + /missing commands: wrapper_reachability_command/u, + ); +} + function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) { return { code, signal, stdout, stderr }; } @@ -942,6 +1122,10 @@ assertNoEnvironmentConfigFallbacks([ assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]); assertNoBlockingNativeFilePicker(tauriRustSource); +runAppInvokeParserRegressionChecks(); + +const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources); + assertContractRecordsMatch( 'AI game creator shell command contract', parseTsCommands(sharedContractSource), @@ -956,7 +1140,7 @@ assertContractRecordsMatch( assertCommandNamesSubset( 'AI game creator shell Tauri handler', - parseAppInvokeCommandNames(appInvokeSource), + appInvokeCommandNames, parseTauriHandlerCommandNames(tauriHandlerSource), ); @@ -969,10 +1153,7 @@ assertCommandNamesSubset( assertCommandNamesSubset( 'AI game creator shell App invoke or explicit native-only allowlist', parseTauriHandlerCommandNames(tauriHandlerSource), - [ - ...parseAppInvokeCommandNames(appInvokeSource), - ...allowedUncalledTauriCommands, - ], + [...appInvokeCommandNames, ...allowedUncalledTauriCommands], ); assertCommandNamesSubset( @@ -981,6 +1162,12 @@ assertCommandNamesSubset( parseTauriHandlerCommandNames(tauriHandlerSource), ); +assertCommandNamesDisjoint( + 'AI game creator shell App invoke and explicit native-only allowlist', + appInvokeCommandNames, + allowedUncalledTauriCommands, +); + const tauriHandlerCommandNames = parseTauriHandlerCommandNames(tauriHandlerSource); if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) { diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6a0ef530b..1bc0f29ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-22.2", + "version": "2026-08-22.3", "skills": [ { "name": "agc-project-structure", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 32487264b..6a8cb991b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -21,12 +21,16 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000; const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; +const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(250); pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str = "codex-app-server-terminal-unknown:"; pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str = "codex-app-server-error:"; -const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str = +const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str = "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence."; +const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str = + "You are Taonier (陶泥儿), Genarrative's game-creation assistant. Present yourself to users as 陶泥儿; do not use Codex, ChatGPT, OpenAI, model, or generic AI assistant as your name or public identity. Codex app-server is only an internal execution technology: mention it only when the user explicitly asks about the underlying implementation, while still identifying yourself as 陶泥儿. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence."; type RpcResult = Result; @@ -305,6 +309,7 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { AgentMessageDelta(String), + Activity(&'static str), Item { completed: bool, params: serde_json::Value, @@ -313,6 +318,12 @@ enum CodexTurnEvent { TransportClosed(String), } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DirectCodexTurnObservation { + AccumulatedText(String), + Activity(&'static str), +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CodexAppServerWorkspaceMode { /// The legacy AGC ToolHost path: use a throwaway workspace and keep the @@ -380,6 +391,51 @@ fn update_active_direct_mcp_tool_calls( } } +fn direct_codex_safe_activity_for_item(item_type: &str) -> &'static str { + match item_type { + "fileChange" => "file-change", + "commandExecution" => "validation", + "mcpToolCall" => "controlled-tool", + "contextCompaction" | "webSearch" => "project-inspection", + "agentMessage" => "response-finalization", + "userMessage" | "plan" | "reasoning" => "understanding", + _ => "understanding", + } +} + +fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static str> { + match method { + "turn/started" + | "turn/plan/updated" + | "item/plan/delta" + | "item/reasoning/summaryTextDelta" + | "item/reasoning/summaryPartAdded" + | "item/reasoning/textDelta" => Some("understanding"), + "item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"), + "item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-change"), + "command/exec/outputDelta" + | "process/outputDelta" + | "item/commandExecution/outputDelta" + | "model/verification" => Some("validation"), + _ => None, + } +} + +fn should_emit_direct_codex_activity( + last_activity: &mut Option<(&'static str, std::time::Instant)>, + activity: &'static str, +) -> bool { + let now = std::time::Instant::now(); + if last_activity.is_some_and(|(previous, observed_at)| { + previous == activity + && now.saturating_duration_since(observed_at) < DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + }) { + return false; + } + *last_activity = Some((activity, now)); + true +} + fn game_creator_codex_app_server_idle_timeout_ms( workspace_mode: CodexAppServerWorkspaceMode, request_timeout_ms: u64, @@ -1388,7 +1444,7 @@ impl CodexAppServerConnection { let base_instructions = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_base_instructions(&request) } else { - DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string() + CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.to_string() }; let params = codex_app_server_thread_start_params( model, @@ -1464,11 +1520,23 @@ impl CodexAppServerConnection { } async fn run_turn( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + ) -> Result { + self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) + .await + } + + async fn run_turn_with_direct_observer( &self, snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, request: LlmRunRequest, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; @@ -1587,6 +1655,11 @@ impl CodexAppServerConnection { match event { Some(CodexTurnEvent::AgentMessageDelta(delta)) => { streamed_text.push_str(&delta); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::AccumulatedText( + streamed_text.clone(), + )); + } if let Some(callback) = on_agent_message_delta.as_deref_mut() { callback(&platform_llm::LlmStreamDelta { accumulated_text: streamed_text.clone(), @@ -1595,12 +1668,22 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::Activity(activity)) => { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::Activity(activity)); + } + } Some(CodexTurnEvent::Item { completed, params }) => { if let Some(item) = params.get("item") { let item_type = item .get("type") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::Activity( + direct_codex_safe_activity_for_item(item_type), + )); + } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -1839,6 +1922,8 @@ async fn read_game_creator_codex_app_server_stdout( stdout: tokio::process::ChildStdout, ) { let mut reader = BufReader::new(stdout); + let mut last_direct_activity_by_turn = + HashMap::>::new(); loop { let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await { Ok(Some(buffer)) => buffer, @@ -2028,10 +2113,12 @@ async fn read_game_creator_codex_app_server_stdout( .get("method") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + let safe_activity = direct_codex_safe_activity_for_notification(method); if !matches!( method, "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" - ) { + ) && safe_activity.is_none() + { continue; } let params = message @@ -2050,24 +2137,37 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - let event = match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - CodexTurnEvent::AgentMessageDelta(delta.to_string()) + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } + } + let event = if let Some(activity) = safe_activity { + CodexTurnEvent::Activity(activity) + } else { + match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + CodexTurnEvent::AgentMessageDelta(delta.to_string()) + } + "item/started" | "item/completed" => CodexTurnEvent::Item { + completed: method == "item/completed", + params, + }, + _ => CodexTurnEvent::Terminal(params), } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - _ => CodexTurnEvent::Terminal(params), }; let sender = if method == "turn/completed" { + last_direct_activity_by_turn.remove(&turn_id); inner.turns.lock().await.remove(&turn_id) } else { inner.turns.lock().await.get(&turn_id).cloned() @@ -2334,6 +2434,31 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, +) -> Result { + direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None) + .await +} + +pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, + observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send), +) -> Result { + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + user_prompt, + Some(observer), + ) + .await +} + +async fn direct_game_creator_codex_chat_at_with_optional_observer( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, + observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let codex_root = if let Some(path) = root .to_str() @@ -2381,7 +2506,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( .await .map_err(|error| error.to_string())?; connection - .run_turn(&snapshot, &config.llm, request, None) + .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -2470,6 +2595,36 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + #[test] + fn direct_item_activities_are_closed_safe_categories() { + let allowed = [ + "understanding", + "project-inspection", + "file-change", + "controlled-tool", + "validation", + "response-finalization", + ]; + for item_type in [ + "fileChange", + "commandExecution", + "mcpToolCall", + "contextCompaction", + "webSearch", + "agentMessage", + "userMessage", + "plan", + "reasoning", + "SECRET_TOOL_/private/project/api_key=leak", + ] { + let activity = direct_codex_safe_activity_for_item(item_type); + assert!(allowed.contains(&activity)); + assert!(!activity.contains("SECRET_TOOL")); + assert!(!activity.contains("/private/project")); + assert!(!activity.contains("api_key")); + } + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), @@ -2525,6 +2680,16 @@ mod tests { assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则")); } + #[test] + fn direct_codex_missing_system_prompt_uses_taonier_identity_only() { + let request = LlmRunRequest::single_turn("", "你是谁"); + let instructions = direct_codex_base_instructions(&request); + assert!(instructions.contains("Taonier (陶泥儿)")); + assert!(instructions.contains("still identifying yourself as 陶泥儿")); + assert!(!instructions.starts_with("You are Codex")); + assert!(CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.starts_with("You are Codex")); + } + #[test] fn direct_project_uses_bounded_idle_and_active_mcp_windows() { let request_timeout_ms = 180_000; @@ -3121,8 +3286,16 @@ printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' IFS= read -r turn_start case "$turn_start" in *'"method":"turn/start"'*'"outputSchema"'*) ;; *) exit 46 ;; esac printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECRET_TOOL /private/project api_key=must-not-leak"}}' +printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECOND_SECRET_PROGRESS"}}' +printf '%s\n' '{"method":"item/fileChange/patchUpdated","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"change-1","patch":"*** SECRET PATCH /private/project"}}' +printf '%s\n' '{"method":"item/commandExecution/outputDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"command-1","delta":"Bearer secret-command-output"}}' +printf '%s\n' '{"method":"turn/plan/updated","params":{"threadId":"thread-1","turnId":"turn-1","explanation":"private reasoning must not leak","plan":[]}}' +printf '%s\n' '{"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"hidden reasoning must not leak"}}' printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":"}}' -printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' +printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak"}}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' while IFS= read -r line; do :; done "#, @@ -3142,11 +3315,83 @@ while IFS= read -r line; do :; done let mut streamed = String::new(); let mut on_delta = |delta: &platform_llm::LlmStreamDelta| streamed.push_str(&delta.delta_text); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); let response = connection - .run_turn(&test_snapshot(), &llm, tool_request(), Some(&mut on_delta)) + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + Some(&mut on_delta), + Some(&mut observer), + ) .await .expect("run fake app-server turn"); + drop(observer); assert_eq!(streamed, "{\"toolCalls\":"); + assert_eq!( + observations.first(), + Some(&DirectCodexTurnObservation::Activity("understanding")), + "turn/started must produce safe activity before terminal completion" + ); + let delta_index = observations + .iter() + .position(|observation| { + *observation + == DirectCodexTurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) + }) + .expect("agent message delta observation"); + assert!( + observations[..delta_index] + .iter() + .filter(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity(_) + )) + .count() + >= 4, + "real long-tool protocol activity must be visible before final answer delta" + ); + assert_eq!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("controlled-tool") + }) + .count(), + 1, + "rapid same-category MCP progress must be coalesced" + ); + for expected in ["file-change", "validation"] { + assert!(observations.contains(&DirectCodexTurnObservation::Activity(expected))); + } + assert!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("understanding") + }) + .count() + >= 2, + "turn start and later plan activity must both remain visible" + ); + assert_eq!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("response-finalization") + }) + .count(), + 2, + "item/started and item/completed must both emit a safe activity" + ); + let observation_debug = format!("{observations:?}"); + assert!(!observation_debug.contains("SECRET_TOOL")); + assert!(!observation_debug.contains("SECOND_SECRET_PROGRESS")); + assert!(!observation_debug.contains("/private/project")); + assert!(!observation_debug.contains("api_key")); + assert!(!observation_debug.contains("Bearer")); + assert!(!observation_debug.contains("hidden reasoning")); assert_eq!(response.response_id.as_deref(), Some("thread-1")); assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index dda424c47..72d35874c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024; const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,Codex 不直接保存或伪造项目版本。"; +const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; +const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; +const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。你可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -1520,13 +1523,14 @@ fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result { let skill_index = render_agc_skill_pack_index()?; let mut sections = vec![ - "你是 Genarrative 的唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体和唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用内部执行引擎提供的原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(), "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "提示词与技能:系统上下文只给出审核 Skill 索引,不预装完整正文。根据当前用户意图选择最少的相关 Skill,并通过 Codex 原生 Skill 机制按需读取其 `SKILL.md` 和一层直接引用。Skill 是执行约束,不是新的 Agent;工具未出现或调用失败时必须如实说明,不能用文字假装获得能力。不要启动 Supervisor、child、harness 或外部编排。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), skill_index, ]; - sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string()); + sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。项目请求由你直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string()); for relative in ["game/index.html", "game/style.css", "game/game.js"] { if let Some(text) = read_context_file_with_limit(root, relative, MAX_DIRECT_PROJECT_FILE_BYTES) @@ -1548,6 +1552,7 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result String { [ - "你是陶泥儿。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。", + "你是陶泥儿,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型或通用 AI 助手当作自己的名称或对外身份。只有用户明确询问底层实现时才可如实说明内部使用 Codex app-server,同时仍以陶泥儿自称。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。", "当前没有打开任何用户项目。普通对话(例如问候、日期、知识问答)请直接正常回答。不要创建、读取或修改项目文件,不要生成素材,不要启动预览、试玩、发布、版本登记或任何付费外部动作。", "如果用户明确希望开始创作游戏,且需求已经足以开始,请把回复的第一行严格写为 [[AGC_CREATE_PROJECT]],随后用简洁中文说明将创建项目并继续创作。这个标记是唯一允许的受限创建项目请求;绝不能为普通问答、素材查看、知识问答或含糊想法输出它。不要自行创建目录。只有用户已在项目工作台中打开工作区后,才能在该项目对话中执行文件修改或游戏验证。", "不要输出或请求 API Key、Token、Cookie、auth.json、.env、用户路径或内部实现细节。遇到当前无项目无法执行的请求,请如实说明边界和下一步。", @@ -1729,6 +1734,21 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( root: &Path, prompt: &str, creation_type: Option<&str>, +) -> Result { + run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + prompt, + creation_type, + None, + ) + .await +} + +async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root: &Path, + prompt: &str, + creation_type: Option<&str>, + turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -1741,9 +1761,23 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( } direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); - match run_direct_game_creator_turn_inner(root, prompt, creation_type).await { - Ok(reply) => Ok(reply), - Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), + if let Some(emitter) = turn_emitter { + emitter.emit("accepted", Some("request-accepted"), None); + } + match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await { + Ok(reply) => { + if let Some(emitter) = turn_emitter { + emitter.emit("completed", Some("none"), Some(reply.clone())); + } + Ok(reply) + } + Err(failure) => { + let error = record_direct_codex_turn_failure(root, failure); + if let Some(emitter) = turn_emitter { + emitter.emit("failed", Some("none"), None); + } + Err(error) + } } } @@ -1751,16 +1785,68 @@ async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, creation_type: Option<&str>, + turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, ) -> Result { - run_direct_game_creator_turn_with_creation_type( - root, - prompt, - creation_type, - |system_prompt, user_prompt| async move { - direct_game_creator_codex_chat_at(root, system_prompt, user_prompt).await - }, - ) - .await + emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); + if let Some(emitter) = turn_emitter { + emitter.emit("running", Some("understanding"), None); + } + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let reply = if let Some(emitter) = turn_emitter { + let emitter = emitter.clone(); + let mut has_streamed = false; + let mut latest_accumulated_text = None; + let mut observer = move |observation: DirectCodexTurnObservation| match observation { + DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + has_streamed = true; + latest_accumulated_text = Some(accumulated_text.clone()); + emitter.emit("streaming", None, Some(accumulated_text)); + } + DirectCodexTurnObservation::Activity(activity) => { + emitter.emit( + if has_streamed { "streaming" } else { "running" }, + Some(activity), + latest_accumulated_text.clone(), + ); + } + }; + direct_game_creator_codex_chat_at_with_observer( + root, + system_prompt, + prompt.to_string(), + &mut observer, + ) + .await + } else { + direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await + } + .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + if let Some(emitter) = turn_emitter { + emitter.emit( + "finalizing", + Some("response-finalization"), + Some(reply.clone()), + ); + } + if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + emit_direct_game_creator_progress( + root, + "project.sync", + "检测到游戏文件更新,正在同步客户端资源", + ); + if let Some(emitter) = turn_emitter { + emitter.emit("finalizing", Some("file-change"), Some(reply.clone())); + } + sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + Ok(reply) } /// Default product path: one user message becomes one turn on the same @@ -2011,16 +2097,41 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Ok(uuid::Uuid::new_v4().simple().to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, creation_type: Option, + client_turn_id: Option, ) -> Result { - run_direct_game_creator_turn_at_with_creation_type( - Path::new(project_path.trim()), + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id); + run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, &prompt, creation_type.as_deref(), + Some(&turn_emitter), ) .await } @@ -2037,11 +2148,55 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + #[test] + fn client_turn_id_is_strictly_normalized_and_bounded() { + assert_eq!( + normalize_direct_client_turn_id(Some(" Abc123-def ")).expect("valid id"), + "Abc123-def" + ); + assert!(normalize_direct_client_turn_id(Some("short")).is_err()); + assert!(normalize_direct_client_turn_id(Some("abcdef/path")).is_err()); + assert!(normalize_direct_client_turn_id(Some(&"a".repeat(161))).is_err()); + let generated = normalize_direct_client_turn_id(None).expect("generated id"); + assert_eq!(generated.len(), 32); + assert!(generated.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + + #[test] + fn direct_turn_update_payload_is_the_exact_camel_case_contract() { + let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { + project_path: "/project".to_string(), + turn_id: "abcdef-123456".to_string(), + sequence: 7, + status: "streaming".to_string(), + activity: None, + accumulated_text: Some("partial".to_string()), + updated_at: 42, + }) + .expect("serialize direct update"); + assert_eq!( + value, + serde_json::json!({ + "projectPath": "/project", + "turnId": "abcdef-123456", + "sequence": 7, + "status": "streaming", + "activity": null, + "accumulatedText": "partial", + "updatedAt": 42, + }) + ); + } + #[test] fn system_prompt_is_bounded_and_declares_direct_runtime() { let prompt = build_direct_codex_system_prompt(Path::new(".")).expect("build direct system prompt"); - assert!(prompt.contains("唯一执行智能体")); + assert!(prompt.contains("你是陶泥儿")); + assert!(prompt.contains("对外身份合同")); + assert!(prompt.contains("不要把 Codex")); + assert!(prompt.contains("仍以陶泥儿自称")); + assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); } @@ -2115,6 +2270,10 @@ mod tests { fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { let prompt = build_direct_codex_home_system_prompt(); + assert!(prompt.contains("你是陶泥儿")); + assert!(prompt.contains("不要把 Codex")); + assert!(prompt.contains("仍以陶泥儿自称")); + assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("当前没有打开任何用户项目")); assert!(prompt.contains("不要创建、读取或修改项目文件")); assert!(prompt.contains("不要生成素材")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 895ff838d..4b26d3f8c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -28,6 +28,73 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag ); } +#[derive(Clone)] +pub(crate) struct DirectGameCreatorTurnUpdateEmitter { + project_path: String, + turn_id: String, + sequence: Arc, +} + +impl DirectGameCreatorTurnUpdateEmitter { + pub(crate) fn new(root: &Path, turn_id: String) -> Self { + Self { + project_path: root.to_string_lossy().into_owned(), + turn_id, + sequence: Arc::new(AtomicU64::new(0)), + } + } + + pub(crate) fn emit( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + ) { + let status_is_allowed = matches!( + status, + "accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed" + ); + let activity_is_allowed = activity.is_none_or(|activity| { + matches!( + activity, + "request-accepted" + | "understanding" + | "project-inspection" + | "file-change" + | "controlled-tool" + | "validation" + | "response-finalization" + | "none" + ) + }); + debug_assert!(status_is_allowed && activity_is_allowed); + if !status_is_allowed || !activity_is_allowed { + return; + } + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; + let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64; + let _ = app.emit( + "game-creator-direct-turn-update", + GameCreatorDirectTurnUpdateEvent { + project_path: self.project_path.clone(), + turn_id: self.turn_id.clone(), + sequence, + status: status.to_string(), + activity: activity.map(str::to_string), + accumulated_text, + updated_at, + }, + ); + } +} + pub(crate) fn start_game_creator_manifest_invalidation_event_sink( app: tauri::AppHandle, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 405281980..a7a9f5b80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; +use std::borrow::Cow; use std::collections::BTreeSet; use std::path::{Component, Path}; @@ -122,6 +123,29 @@ fn bundled_skill_file(path: &str) -> Option<&'static [u8]> { .find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes)) } +fn canonical_skill_text_bytes<'a>(path: &str, bytes: &'a [u8]) -> Result, String> { + let text = std::str::from_utf8(bytes) + .map_err(|_| format!("内置 AGC Skill 审核文件 {path} 不是 UTF-8 文本"))?; + if !text.contains("\r\n") { + return Ok(Cow::Borrowed(bytes)); + } + Ok(Cow::Owned(text.replace("\r\n", "\n").into_bytes())) +} + +fn update_skill_content_digest( + digest: &mut Sha256, + relative: &str, + bundled_path: &str, + bytes: &[u8], +) -> Result<(), String> { + let canonical_bytes = canonical_skill_text_bytes(bundled_path, bytes)?; + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(canonical_bytes.as_ref()); + digest.update([0]); + Ok(()) +} + fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result { let mut files = entry.files.clone(); files.sort(); @@ -133,10 +157,7 @@ fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result Result { pub(crate) fn agc_skill_pack_fingerprint() -> Result { validated_skill_pack_manifest()?; - Ok(format!("{:x}", Sha256::digest(AGC_SKILL_PACK_MANIFEST))) + let canonical_manifest = canonical_skill_text_bytes("manifest.json", AGC_SKILL_PACK_MANIFEST)?; + Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) } pub(crate) fn render_agc_skill_pack_index() -> Result { @@ -260,12 +282,13 @@ pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result String { + let mut digest = Sha256::new(); + update_skill_content_digest( + &mut digest, + "agents/openai.yaml", + "agc-project-structure/agents/openai.yaml", + bytes, + ) + .expect("hash reviewed skill text"); + format!("{:x}", digest.finalize()) + } + + assert_eq!( + digest(b"interface:\n display_name: AGC\n short_description: test\n"), + digest(b"interface:\r\n display_name: AGC\n short_description: test\r\n") + ); + } + #[test] fn skill_pack_installs_under_isolated_home_without_full_body_in_index() { let home = tempfile::tempdir().expect("temporary home"); @@ -308,6 +351,14 @@ mod tests { .path() .join(".agents/skills/taonier-art-assets/SKILL.md") .is_file()); + let installed_agent_metadata = std::fs::read( + home.path() + .join(".agents/skills/agc-project-structure/agents/openai.yaml"), + ) + .expect("read installed agent metadata"); + assert!(!installed_agent_metadata + .windows(2) + .any(|pair| pair == b"\r\n")); let index = render_agc_skill_pack_index().expect("render index"); assert!(index.contains("taonier-art-assets")); assert!(index.contains("agc_tools.taonier_prepare_game_art")); 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 d4cda2bbe..856e9aee1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -723,6 +723,18 @@ struct GameCreatorAgentProgressEvent { message: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorDirectTurnUpdateEvent { + project_path: String, + turn_id: String, + sequence: u64, + status: String, + activity: Option, + accumulated_text: Option, + updated_at: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index c3b0bb6bd..41628c921 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -2074,6 +2074,12 @@ fn stage_asset_canvas_image_with_token_at( let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if matches!( + draft.status, + AssetCanvasDraftStatus::Cancelled | AssetCanvasDraftStatus::Committed + ) { + return Err("素材画布草稿已取消或提交,不能继续暂存图片".to_string()); + } if draft.revision != input.expected_draft_revision { return Ok(StageAssetCanvasImageResult { status: "conflict".to_string(), @@ -2096,48 +2102,15 @@ fn stage_asset_canvas_image_with_token_at( } None => new_asset_canvas_token()?, }; - if stable_token.is_some() { - match read_staged_image_locked(root, &token) { - Ok((metadata, existing_bytes)) => { - if metadata.project_id != manifest.project_id - || metadata.draft_id != input.draft_id - || metadata.draft_revision != draft.revision - || metadata.media_type != media_type - || existing_bytes != input.bytes - { - return Err("稳定 staging token 已绑定到不同图片".to_string()); - } - return Ok(StageAssetCanvasImageResult { - status: "staged".to_string(), - staged_image_token: Some(token), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_type: Some(metadata.media_type), - sha256: Some(metadata.sha256), - byte_length: Some(metadata.byte_length), - pixel_width: Some(metadata.pixel_width), - pixel_height: Some(metadata.pixel_height), - expires_at: Some(metadata.expires_at), - draft: None, - }); - } - Err(error) if !error.contains("不存在") => return Err(error), - Err(_) => {} - } - } let extension = media_extension(&media_type)?; let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"); - install_new_asset_canvas_file( - &resolve_local_project_path(root, &image_relative)?, - &input.bytes, - "素材画布 staging 图片", - )?; + let image_path = resolve_local_project_path(root, &image_relative)?; let expires_at = asset_canvas_now() .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) .min(ASSET_CANVAS_MAX_SAFE_INTEGER); - let metadata = AssetCanvasStagedImage { + let expected_metadata = AssetCanvasStagedImage { schema_version: "game-creator-asset-canvas-staging.v1".to_string(), - project_id: manifest.project_id, + project_id: manifest.project_id.clone(), draft_id: input.draft_id.clone(), draft_revision: draft.revision, staged_image_token: token.clone(), @@ -2148,6 +2121,84 @@ fn stage_asset_canvas_image_with_token_at( pixel_height: height, expires_at, }; + if stable_token.is_some() { + let existing_metadata = read_staged_image_metadata_locked(root, &token)?; + if existing_metadata.as_ref().is_some_and(|metadata| { + metadata.project_id != expected_metadata.project_id + || metadata.draft_id != expected_metadata.draft_id + || metadata.draft_revision != expected_metadata.draft_revision + || metadata.media_type != expected_metadata.media_type + || metadata.sha256 != expected_metadata.sha256 + || metadata.byte_length != expected_metadata.byte_length + || metadata.pixel_width != expected_metadata.pixel_width + || metadata.pixel_height != expected_metadata.pixel_height + }) { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + for candidate_extension in ["png", "jpg", "webp"] { + if candidate_extension == extension { + continue; + } + let candidate = resolve_local_project_path( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{candidate_extension}"), + )?; + match fs::symlink_metadata(candidate) { + Ok(_) => return Err("稳定 staging token 已绑定到不同图片".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), + } + } + let existing_image = match fs::symlink_metadata(&image_path) { + Ok(_) => Some(open_and_validate_image_file( + &image_path, + &media_type, + Some(&expected_metadata.sha256), + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), + }; + if existing_image + .as_ref() + .is_some_and(|(bytes, existing_width, existing_height)| { + bytes != &input.bytes || *existing_width != width || *existing_height != height + }) + { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + if existing_image.is_none() { + install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; + } + let metadata = existing_metadata.unwrap_or(expected_metadata); + if read_staged_image_metadata_locked(root, &token)?.is_none() { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + )?; + } + let (metadata, existing_bytes) = read_staged_image_locked(root, &token)?; + if existing_bytes != input.bytes { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + return Ok(StageAssetCanvasImageResult { + status: "staged".to_string(), + staged_image_token: Some(token), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_type: Some(metadata.media_type), + sha256: Some(metadata.sha256), + byte_length: Some(metadata.byte_length), + pixel_width: Some(metadata.pixel_width), + pixel_height: Some(metadata.pixel_height), + expires_at: Some(metadata.expires_at), + draft: None, + }); + } + install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; + let metadata = expected_metadata; write_agent_runtime_json_sidecar_with_max_bytes( root, &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), @@ -2867,18 +2918,8 @@ fn read_staged_image_locked( token: &str, ) -> Result<(AssetCanvasStagedImage, Vec), String> { validate_plain_component(token, "stagedImageToken", 128)?; - let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - 16 * 1024, - )? - .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; - if metadata.staged_image_token != token - || metadata.schema_version != "game-creator-asset-canvas-staging.v1" - { - return Err("素材画布 staging 身份无效".to_string()); - } + let metadata = read_staged_image_metadata_locked(root, token)? + .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; let extension = media_extension(&metadata.media_type)?; let path = resolve_local_project_path( root, @@ -2895,6 +2936,27 @@ fn read_staged_image_locked( Ok((metadata, bytes)) } +fn read_staged_image_metadata_locked( + root: &Path, + token: &str, +) -> Result, String> { + validate_plain_component(token, "stagedImageToken", 128)?; + let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + 16 * 1024, + )?; + if let Some(metadata) = metadata.as_ref() { + if metadata.staged_image_token != token + || metadata.schema_version != "game-creator-asset-canvas-staging.v1" + { + return Err("素材画布 staging 身份无效".to_string()); + } + } + Ok(metadata) +} + fn committed_result_from_ledger( root: &Path, ledger: &AssetCanvasCommitLedger, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 9e804fb2b..247792af2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -1046,6 +1046,72 @@ fn set_private_phase( write_generation_ledger(root, ledger) } +fn retryable_credential_failure_phase( + ledger: &AssetCanvasGenerationLedger, +) -> Option { + if ledger.phase != GenerationLedgerPhase::Failed + || !matches!( + ledger.error_code.as_deref(), + Some("configuration-missing" | "authentication-required") + ) + { + return None; + } + Some(if ledger.operation_id.is_some() { + GenerationLedgerPhase::ReconciliationRequired + } else if ledger.request_body_json.is_some() { + GenerationLedgerPhase::Prepared + } else { + GenerationLedgerPhase::ContextPreparing + }) +} + +fn migrate_retryable_credential_failure( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + let Some(phase) = retryable_credential_failure_phase(ledger) else { + return Ok(false); + }; + ensure_generation_draft_active(root, ledger)?; + ledger.phase = phase; + write_generation_ledger(root, ledger)?; + Ok(true) +} + +fn validate_generation_draft_active( + ledger: &AssetCanvasGenerationLedger, + draft: &AssetCanvasDraft, +) -> Result<(), String> { + if draft.project_id != ledger.project_id + || draft.draft_id != ledger.draft_id + || draft.intent != ledger.intent + || draft.source_asset_id != ledger.source_asset_id + { + return Err("素材画布生成账本与草稿身份不一致".to_string()); + } + if draft.status == AssetCanvasDraftStatus::Cancelled { + return Err( + "generation-cancelled: 素材画布草稿已取消;私有生成账本将保留,但不得继续公开投影或提交资产" + .to_string(), + ); + } + if draft.status == AssetCanvasDraftStatus::Committed { + return Err("素材画布草稿已经提交,不能继续推进其它生成账本".to_string()); + } + Ok(()) +} + +fn ensure_generation_draft_active( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft) +} + fn upsert_public_generation_record( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -1055,12 +1121,7 @@ fn upsert_public_generation_record( let _lock = acquire_asset_canvas_draft_lock(root)?; let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.project_id != ledger.project_id - || draft.intent != ledger.intent - || draft.source_asset_id != ledger.source_asset_id - { - return Err("素材画布生成账本与草稿身份不一致".to_string()); - } + validate_generation_draft_active(ledger, &draft)?; let now = asset_canvas_now(); let output_asset_id = ledger .commit_result @@ -2676,6 +2737,10 @@ fn mark_generation_error( error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result<(), String> { + // Cancellation is authoritative and must not be overwritten by a late + // network/commit error. The private ledger keeps its last durable phase as + // evidence while all public projection and candidate persistence stop. + ensure_generation_draft_active(root, ledger)?; set_private_phase( root, ledger, @@ -2900,11 +2965,13 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::Archived { return Err("素材画布失败生成已归档".to_string()); } + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } + ensure_generation_draft_active(root, &ledger)?; bind_generation_platform_owner(root, &mut ledger, platform_session)?; ensure_frozen_generation_platform_session(platform_session)?; let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); @@ -2964,14 +3031,19 @@ async fn reconcile_generation( { Ok(context) => context, Err(error) => { - let (reconciliation, code) = if error.contains("HTTP 401") { + let (_reconciliation, code) = if error.contains("HTTP 401") { (true, "authentication-required") } else if error.contains("HTTP 403") { (false, "permission-denied") } else { (false, "platform-service-configuration") }; - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } }; @@ -2982,8 +3054,12 @@ async fn reconcile_generation( ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await { let code = error.code(); - let reconciliation = code == "authentication-required"; - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; @@ -2998,6 +3074,11 @@ async fn reconcile_generation( GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired ) && ledger.operation_id.is_none() { + ensure_generation_draft_active(root, &ledger)?; + // Context/reference preparation can contain several awaits. Re-check the + // exact account snapshot after those side effects and immediately before + // the first chargeable/idempotent remote submission. + ensure_frozen_generation_platform_session(platform_session)?; let endpoint = ledger .endpoint .as_deref() @@ -3061,12 +3142,17 @@ async fn reconcile_generation( .await; } } - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(if code == "authentication-required" { - sanitized_generation_error(code) + if code == "authentication-required" && !reconciliation { + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::Prepared, + Some(code), + )?; } else { - sanitized_classified_generation_error(code, reconciliation) - }); + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + } + return Err(sanitized_classified_generation_error(code, reconciliation)); } let submission = match response.json::().await { Ok(value) => value, @@ -3183,6 +3269,7 @@ async fn reconcile_generation( let download = match download_result { Ok(Some(download)) => { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; download } _ => { @@ -3239,6 +3326,7 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::MediaDownloaded { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; if !synchronize_staged_image_revision(root, &mut ledger)? { mark_generation_error( root, @@ -3461,7 +3549,11 @@ pub(crate) async fn generate_asset_canvas_image_at( Ok(value) => value, Err(_) => { let code = "configuration-missing"; - mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + // Credential availability is not a durable business outcome. Keep + // the original private identity retryable so login/configuration + // repair can resume the same idempotency keys. + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; return Err(sanitized_generation_error(code)); } }; @@ -3613,6 +3705,49 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + let draft_status = { + let _lock = acquire_asset_canvas_draft_lock(root)?; + read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())? + .status + }; + if draft_status == AssetCanvasDraftStatus::Cancelled { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } + let mut recoverable_generation_ids = Vec::new(); + for generation_id in generation_ids { + let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + continue; + }; + if ledger.project_id == input.expected_project_id + && ledger.draft_id == input.draft_id + && ledger.phase != GenerationLedgerPhase::AssetDurableCommitted + && (ledger.phase != GenerationLedgerPhase::Failed + || retryable_credential_failure_phase(&ledger).is_some()) + { + recoverable_generation_ids.push(generation_id); + } + } + if recoverable_generation_ids.is_empty() { + restore_unacknowledged_candidate_layers_at( + root, + &input.expected_project_id, + &input.draft_id, + )?; + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; @@ -3620,26 +3755,12 @@ pub(crate) async fn recover_asset_canvas_generations_at( let mut resumed = Vec::new(); let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); - for generation_id in generation_ids { - let Some(initial_ledger) = read_generation_ledger(root, &generation_id)? else { - continue; - }; - if initial_ledger.project_id != input.expected_project_id - || initial_ledger.draft_id != input.draft_id - || matches!( - initial_ledger.phase, - GenerationLedgerPhase::CandidateReady - | GenerationLedgerPhase::AssetDurableCommitted - | GenerationLedgerPhase::Failed - | GenerationLedgerPhase::Archived - ) - { - continue; - } - let _guard = generation_singleflight_lock(&initial_ledger.project_id, &generation_id).await; + for generation_id in recoverable_generation_ids { + let _guard = generation_singleflight_lock(&input.expected_project_id, &generation_id).await; let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id || matches!( @@ -3652,6 +3773,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( { continue; } + if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { + continue; + } match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { service_identity_confirmations.push(confirmation); @@ -3661,9 +3785,6 @@ pub(crate) async fn recover_asset_canvas_generations_at( Err(_) => {} } resumed.push(generation_id.clone()); - if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { - continue; - } match reconcile_generation( root, ledger, @@ -6450,5 +6571,366 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retryable generation ledger") + .expect("missing-login ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); + assert_eq!(ledger.idempotency_key, input.idempotency_key); + assert_eq!(ledger.commit_id, input.commit_id); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("login recovery address") + ); + let signed_url = format!("{base_url}/login-recovered.png"); + let server_signed_url = signed_url.clone(); + let recovered_png = test_png(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set login recovery fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "login recovery fixture", + request_index, + ); + let request = read_http_request(&mut stream); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "login-recovery-project", + "title": "阶段五缺失配置测试", + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "login-recovery-folder", + "label": "阶段五缺失配置测试", + }]}}}), + ); + } else if request.starts_with("POST /api/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request.starts_with( + "GET /api/runtime/external-generation/jobs/login-recovery-operation ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "login-recovery-resource", + "objectKey": "generated/login-recovered.png", + "assetObjectId": "login-recovery-object", + }} + }}), + ); + } else if request.starts_with("GET /api/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /login-recovered.png ") { + write_png(&mut stream, &recovered_png); + } else { + panic!("unexpected login recovery request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "original-login-owner", + "fresh-login-token", + &base_url, + ); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: ledger.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("fresh login resumes original private identity"); + server.join().expect("join login recovery server"); + assert_eq!( + recovered.result.resumed_generation_ids, + vec![input.generation_id.clone()] + ); + let resumed = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read resumed login ledger") + .expect("resumed login ledger exists"); + assert_eq!(resumed.idempotency_key, input.idempotency_key); + assert_eq!(resumed.commit_id, input.commit_id); + assert_eq!( + resumed.platform_owner_user_id.as_deref(), + Some("original-login-owner") + ); + assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted); + assert_eq!(resumed.error_code, None); + assert!(resumed.commit_result.is_some()); + let manifest = + current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); + assert_eq!(manifest.assets.len(), 1); + } + + #[tokio::test] + async fn recovery_filters_mismatched_and_terminal_ledgers_before_resolving_credentials() { + let project_id = "generation-recovery-prefilter"; + let (directory, draft) = create_generation_fixture(project_id, "生成恢复预过滤测试"); + let mut terminal = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + terminal.phase = GenerationLedgerPhase::Failed; + terminal.error_code = Some("generation-failed".to_string()); + write_generation_ledger(directory.path(), &mut terminal).expect("write terminal ledger"); + let mut mismatched = accepted_ledger( + "other-project", + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut mismatched) + .expect("write mismatched ledger"); + + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + |_| {}, + ) + .await + .expect("terminal and mismatched ledgers do not require credentials"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + assert!(recovered.events.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn platform_session_is_revalidated_after_context_and_before_remote_submit() { + let project_id = "generation-session-submit-gate"; + let project_name = "生成提交登录态复验测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind session gate server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("session gate address") + ); + let (ready_sender, ready_receiver) = mpsc::channel(); + let (resume_sender, resume_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set session gate fixture nonblocking"); + for request_index in 0..2 { + let mut stream = accept_generation_fixture_connection( + &listener, + "session gate fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture session gate request"); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + ready_sender.send(()).expect("signal context prepared"); + resume_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("resume context response after session switch"); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else { + panic!("unexpected session gate request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "session-owner", + "session-token", + &base_url, + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "切换登录态后不得提交远端生成", + ); + let root = directory.path().to_path_buf(); + let generation_id = input.generation_id.clone(); + let generation = + tokio::spawn( + async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, + ); + ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("context preparation reached final await"); + crate::platform_session::clear_platform_session(2); + resume_sender.send(()).expect("release context response"); + let error = generation + .await + .expect("join session gate generation") + .err() + .expect("changed session must stop before submit"); + server.join().expect("join session gate server"); + assert!(error.contains("登录态已变化")); + let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|request| request.starts_with("GET "))); + let ledger = read_generation_ledger(directory.path(), &generation_id) + .expect("read session gate ledger") + .expect("session gate ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::Prepared); + assert!(ledger.operation_id.is_none()); + } + + #[test] + fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { + let project_id = "cancelled-generation-projection"; + let (directory, draft) = create_generation_fixture(project_id, "取消生成投影测试"); + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); + let cancelled = discard_asset_canvas_draft_at( + directory.path(), + &DiscardAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + }, + ) + .expect("cancel generation draft"); + assert_eq!(cancelled.status, "cancelled"); + + let error = mark_generation_error( + directory.path(), + &mut ledger, + true, + "poll-result-unknown", + &mut |_| {}, + ) + .expect_err("cancelled draft must reject public projection"); + assert!(error.contains("generation-cancelled")); + let persisted = read_generation_ledger(directory.path(), &ledger.generation_id) + .expect("read private cancelled ledger") + .expect("private cancelled ledger exists"); + assert_eq!(persisted.phase, GenerationLedgerPhase::Accepted); + assert_eq!(persisted.error_code, None); + assert_eq!(persisted.operation_id, ledger.operation_id); + let public = read_asset_canvas_draft_at( + directory.path(), + &ReadAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + ) + .expect("read cancelled draft") + .draft + .expect("cancelled draft exists"); + assert_eq!(public.status, AssetCanvasDraftStatus::Cancelled); + assert!(public.generations.is_empty()); + } + + #[test] + fn legacy_failed_credential_ledgers_migrate_by_remote_side_effect_boundary_only() { + let project_id = "legacy-credential-ledger-migration"; + let (directory, draft) = create_generation_fixture(project_id, "旧凭据账本恢复测试"); + for (has_request, has_operation, expected_phase) in [ + (false, false, GenerationLedgerPhase::ContextPreparing), + (true, false, GenerationLedgerPhase::Prepared), + (true, true, GenerationLedgerPhase::ReconciliationRequired), + ] { + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + ledger.phase = GenerationLedgerPhase::Failed; + ledger.error_code = Some("authentication-required".to_string()); + if !has_request { + ledger.request_body_json = None; + ledger.request_body_sha256 = None; + ledger.endpoint = None; + } + if !has_operation { + ledger.operation_id = None; + ledger.poll_after_ms = None; + } + write_generation_ledger(directory.path(), &mut ledger) + .expect("write legacy failed credential ledger"); + assert!( + migrate_retryable_credential_failure(directory.path(), &mut ledger) + .expect("migrate legacy failed credential ledger") + ); + assert_eq!(ledger.phase, expected_phase); + assert_eq!( + ledger.error_code.as_deref(), + Some("authentication-required") + ); + } + + let mut business_failure = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + business_failure.phase = GenerationLedgerPhase::Failed; + business_failure.error_code = Some("generation-rejected".to_string()); + write_generation_ledger(directory.path(), &mut business_failure) + .expect("write definitive business failure"); + assert!( + !migrate_retryable_credential_failure(directory.path(), &mut business_failure) + .expect("leave definitive business failure terminal") + ); + assert_eq!(business_failure.phase, GenerationLedgerPhase::Failed); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index 4073066f4..de65c9910 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -323,6 +323,151 @@ fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasI .expect("stage image") } +#[test] +fn stable_staging_token_repairs_image_first_and_metadata_first_partial_installs() { + for image_first in [true, false] { + let fixture = initialize_fixture(); + let token = Uuid::new_v4().to_string(); + let staging_directory = fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); + fs::create_dir_all(&staging_directory).expect("create partial staging directory"); + let expires_at = asset_canvas_now() + .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) + .min(ASSET_CANVAS_MAX_SAFE_INTEGER); + if image_first { + fs::write(staging_directory.join("image.png"), &fixture.png) + .expect("simulate image-first crash"); + } else { + let metadata = AssetCanvasStagedImage { + schema_version: "game-creator-asset-canvas-staging.v1".to_string(), + project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + draft_revision: fixture.draft.revision, + staged_image_token: token.clone(), + media_type: "image/png".to_string(), + sha256: asset_canvas_sha256(&fixture.png), + byte_length: fixture.png.len() as u64, + pixel_width: 4, + pixel_height: 3, + expires_at, + }; + write_agent_runtime_json_sidecar_with_max_bytes( + fixture.root(), + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + ) + .expect("simulate metadata-first crash"); + } + + let repaired = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect("repair partial stable staging install"); + assert_eq!(repaired.status, "staged"); + assert_eq!(repaired.staged_image_token.as_deref(), Some(token.as_str())); + let (metadata, bytes) = + read_staged_image_locked(fixture.root(), &token).expect("read repaired staging"); + assert_eq!(metadata.project_id, PROJECT_ID); + assert_eq!(metadata.draft_id, fixture.draft.draft_id); + assert_eq!(bytes, fixture.png); + + let replay = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect("replay repaired stable staging install"); + assert_eq!(replay.staged_image_token, repaired.staged_image_token); + } +} + +#[test] +fn stable_staging_token_rejects_conflicting_partial_install() { + let fixture = initialize_fixture(); + let token = Uuid::new_v4().to_string(); + let staging_directory = fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); + fs::create_dir_all(&staging_directory).expect("create conflicting staging directory"); + fs::write(staging_directory.join("image.png"), &fixture.png) + .expect("write conflicting image-first residue"); + let different_png = png_bytes([220, 31, 54, 255]); + let error = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: different_png, + }, + Some(&token), + ) + .expect_err("conflicting half-installed stable token must fail closed"); + assert!(error.contains("摘要不匹配") || error.contains("绑定到不同图片")); + assert!(read_staged_image_metadata_locked(fixture.root(), &token) + .expect("read missing conflicting metadata") + .is_none()); + assert_eq!( + fs::read(staging_directory.join("image.png")).expect("read preserved residue"), + fixture.png + ); +} + +#[test] +fn cancelled_draft_rejects_staging_before_writing_token() { + let fixture = initialize_fixture(); + discard_asset_canvas_draft_at( + fixture.root(), + &DiscardAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + }, + ) + .expect("cancel staging fixture draft"); + let token = Uuid::new_v4().to_string(); + let error = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect_err("cancelled draft must reject stable staging"); + assert!(error.contains("草稿已取消或提交")); + assert!(!fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")) + .exists()); +} + fn commit_input( fixture: &Fixture, draft: &AssetCanvasDraft, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 29142dd75..4d2b7339e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -51,6 +51,7 @@ import type { ChatMessage, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -258,6 +259,37 @@ const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; +function directCodexActivityText(activity: string | null | undefined) { + switch (activity) { + case 'request-accepted': + return '已接收需求'; + case 'understanding': + return '正在理解需求'; + case 'project-inspection': + return '正在检查项目'; + case 'file-change': + return '正在修改项目文件'; + case 'controlled-tool': + return '正在执行受控工具'; + case 'validation': + return '正在验证结果'; + case 'response-finalization': + return '正在整理回复'; + case 'none': + default: + return '陶泥儿正在处理'; + } +} + +const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ + 'accepted', + 'running', + 'streaming', + 'finalizing', + 'completed', + 'failed', +]); + function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -747,6 +779,20 @@ export function App({ ); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); + const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = + useState(null); + const [directCodexTransientReply, setDirectCodexTransientReply] = + useState(''); + const [ + directCodexTransientReplyUpdatedAt, + setDirectCodexTransientReplyUpdatedAt, + ] = useState(null); + const activeDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + lastSequence: number; + receivedDirectUpdate: boolean; + } | null>(null); const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null @@ -766,6 +812,28 @@ export function App({ } return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; } + + function resetDirectCodexTurn() { + activeDirectCodexTurnRef.current = null; + setDirectCodexProgress(''); + setDirectCodexProgressUpdatedAt(null); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + } + + function clearDirectCodexTransientReply(projectPath: string, turnId: string) { + const activeTurn = activeDirectCodexTurnRef.current; + if ( + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId + ) { + return false; + } + activeDirectCodexTurnRef.current = null; + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + return true; + } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -1444,6 +1512,76 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); + useEffect(() => { + if (!directCodexProductRuntime) { + return; + } + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-direct-turn-update', + (event) => { + const payload = event.payload; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + payload.projectPath !== localProjectPathRef.current || + payload.projectPath !== activeTurn.projectPath || + payload.turnId !== activeTurn.turnId || + !Number.isSafeInteger(payload.sequence) || + payload.sequence < 0 || + !DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) || + payload.sequence <= activeTurn.lastSequence + ) { + return; + } + activeTurn.lastSequence = payload.sequence; + activeTurn.receivedDirectUpdate = true; + const updatedAt = + Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 + ? payload.updatedAt + : Date.now(); + if (payload.status === 'failed') { + activeDirectCodexTurnRef.current = null; + setDirectCodexProgress('处理失败,正在同步错误'); + setDirectCodexProgressUpdatedAt(updatedAt); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + return; + } + if (payload.status === 'completed') { + setDirectCodexProgress('回复已生成,正在提交'); + setDirectCodexProgressUpdatedAt(updatedAt); + } else if (payload.activity != null) { + setDirectCodexProgress(directCodexActivityText(payload.activity)); + setDirectCodexProgressUpdatedAt(updatedAt); + } + if (typeof payload.accumulatedText === 'string') { + setDirectCodexTransientReply(payload.accumulatedText); + setDirectCodexTransientReplyUpdatedAt(updatedAt); + } + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // The existing safe progress event remains the activity fallback. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [directCodexProductRuntime]); + useEffect(() => { if (projectSupervisorOnly && !directCodexProductRuntime) { return; @@ -1459,7 +1597,16 @@ export function App({ return; } if (directCodexProductRuntime) { + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + activeTurn.projectPath !== event.payload.projectPath || + activeTurn.receivedDirectUpdate + ) { + return; + } setDirectCodexProgress(event.payload.message); + setDirectCodexProgressUpdatedAt(Date.now()); return; } setMessages((current) => [ @@ -2132,7 +2279,9 @@ export function App({ useLayoutEffect(() => { if ( - (!supervisorChatOnly && !gameChatOnly) || + (!supervisorChatOnly && + !gameChatOnly && + !(projectSupervisorOnly && directCodexProductRuntime)) || !supervisorChatShouldFollowLatestRef.current ) { return; @@ -2146,7 +2295,13 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, + directCodexProgress, + directCodexProgressUpdatedAt, + directCodexTransientReply, + directCodexTransientReplyUpdatedAt, + directCodexProductRuntime, gameChatOnly, + projectSupervisorOnly, supervisorChatOnly, ]); @@ -3259,6 +3414,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); + resetDirectCodexTurn(); if ( gameChatAutoPreviewAuthorizationRef.current?.projectPath !== nextProjectPath @@ -5937,20 +6093,17 @@ export function App({ const directInvoke = resolveTauriInvoke(); const directProjectPath = resolveChatProjectPath(localProject); if (directProjectPath && directInvoke) { - const directAssistantMessageId = directConversationTurnId - ? directCodexConversationMessageId( - directConversationTurnId, - 'assistant', - ) - : undefined; + const clientTurnId = + directConversationTurnId ?? createDirectCodexConversationTurnId(); + const directAssistantMessageId = directCodexConversationMessageId( + clientTurnId, + 'assistant', + ); const appendDirectUserMessageIfMissing = ( current: ChatMessage[], ): ChatMessage[] => { - if (!directConversationTurnId) { - return current; - } const directUserMessageId = directCodexConversationMessageId( - directConversationTurnId, + clientTurnId, 'user', ); return current.some( @@ -5968,17 +6121,50 @@ export function App({ }, ]; }; + const appendDirectAssistantMessage = ( + current: ChatMessage[], + text: string, + ): ChatMessage[] => { + const nextMessage: ChatMessage = { + role: 'assistant', + text, + runtimeOwned: true, + messageId: directAssistantMessageId, + updatedAt: Date.now(), + }; + const withUser = appendDirectUserMessageIfMissing(current); + const existingIndex = withUser.findIndex( + (message) => message.messageId === directAssistantMessageId, + ); + if (existingIndex < 0) { + return [...withUser, nextMessage]; + } + return withUser.map((message, index) => + index === existingIndex ? nextMessage : message, + ); + }; + activeDirectCodexTurnRef.current = { + projectPath: directProjectPath, + turnId: clientTurnId, + lastSequence: -1, + receivedDirectUpdate: false, + }; setChatAgentBusy(true); setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); + setDirectCodexProgressUpdatedAt(Date.now()); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { const directTurnInput: { projectPath: string; prompt: string; + clientTurnId: string; creationType?: HomeCreationType; } = { projectPath: directProjectPath, prompt, + clientTurnId, }; if (creationType) { directTurnInput.creationType = creationType; @@ -5988,19 +6174,12 @@ export function App({ directTurnInput, ); if (localProjectPathRef.current === directProjectPath) { - setMessages((current) => [ - ...appendDirectUserMessageIfMissing(current), - { - role: 'assistant', - text: reply, - runtimeOwned: true, - ...(directAssistantMessageId - ? { messageId: directAssistantMessageId } - : {}), - updatedAt: Date.now(), - }, - ]); - setDirectCodexProgress('陶泥儿已回复,正在刷新项目状态'); + clearDirectCodexTransientReply(directProjectPath, clientTurnId); + setMessages((current) => + appendDirectAssistantMessage(current, reply), + ); + setDirectCodexProgress('正在刷新项目状态'); + setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -6012,19 +6191,11 @@ export function App({ true, ); if (localProjectPathRef.current === directProjectPath) { + clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError(visibleMessage); - setMessages((current) => [ - ...appendDirectUserMessageIfMissing(current), - { - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - ...(directAssistantMessageId - ? { messageId: directAssistantMessageId } - : {}), - updatedAt: Date.now(), - }, - ]); + setMessages((current) => + appendDirectAssistantMessage(current, visibleMessage), + ); } } finally { try { @@ -6034,6 +6205,14 @@ export function App({ } finally { setChatAgentBusy(false); setDirectCodexProgress(''); + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId) + ) { + resetDirectCodexTurn(); + } } } return; @@ -11290,7 +11469,7 @@ export function App({ if (!prompt || chatAgentBusy) { return; } - if (supervisorChatOnly || gameChatOnly) { + if (supervisorChatOnly || gameChatOnly || directCodexProductRuntime) { supervisorChatShouldFollowLatestRef.current = true; } const directConversationTurnId = directCodexProductRuntime @@ -11368,12 +11547,20 @@ export function App({ allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen } runtimeError={projectSupervisorRuntimeError} + directActivity={directCodexProductRuntime ? directCodexProgress : ''} + directActivityUpdatedAt={ + directCodexProductRuntime ? directCodexProgressUpdatedAt : null + } transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + transientReplyUpdatedAt={ + directCodexProductRuntime + ? directCodexTransientReplyUpdatedAt + : projectSupervisorResponseStream?.updatedAt + } hasConversationControls={ directCodexProductRuntime ? false @@ -11427,12 +11614,20 @@ export function App({ runtime={projectSupervisorRuntime} runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} + directActivity={directCodexProductRuntime ? directCodexProgress : ''} + directActivityUpdatedAt={ + directCodexProductRuntime ? directCodexProgressUpdatedAt : null + } transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + transientReplyUpdatedAt={ + directCodexProductRuntime + ? directCodexTransientReplyUpdatedAt + : projectSupervisorResponseStream?.updatedAt + } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} needsUserInput={projectSupervisorNeedsUserInput} @@ -11448,7 +11643,9 @@ export function App({ void handlePendingCommandConfirm()} - onScroll={handleConversationScroll} + onScroll={handleSupervisorChatScroll} onShowEarlierMessages={showEarlierConversationMessages} onSubmit={handleProjectSupervisorOnlySubmit} pendingConfirmation={ @@ -11467,7 +11664,7 @@ export function App({ projectPath={localProject?.projectPath ?? projectPath} transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } visibleMessages={visibleMessages} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 132e92587..7f7ad49fa 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -806,6 +806,34 @@ export interface AgentProgressEvent { message: string; } +export type GameCreatorDirectTurnUpdateStatus = + | 'accepted' + | 'running' + | 'streaming' + | 'finalizing' + | 'completed' + | 'failed'; + +export type GameCreatorDirectTurnActivity = + | 'request-accepted' + | 'understanding' + | 'project-inspection' + | 'file-change' + | 'controlled-tool' + | 'validation' + | 'response-finalization' + | 'none'; + +export interface GameCreatorDirectTurnUpdateEvent { + projectPath: string; + turnId: string; + sequence: number; + status: GameCreatorDirectTurnUpdateStatus; + activity?: GameCreatorDirectTurnActivity | null; + accumulatedText?: string | null; + updatedAt: number; +} + export interface AgentRunControlResult { runId: string; status: string; diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index b6679e652..a3e4a559c 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -68,6 +68,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -345,6 +346,25 @@ function runtimeGenerationTaskFromRecord( }; } +const MODAL_FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function modalFocusableElements(dialog: HTMLElement) { + return Array.from( + dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR), + ).filter( + (element) => + element.getAttribute('aria-hidden') !== 'true' && + !element.closest('[inert]'), + ); +} + type DragState = | { kind: 'pan'; @@ -777,6 +797,10 @@ export function AssetCanvasSurface({ const quickEditOpenRef = useRef(quickEditOpen); const generationStopButtonRef = useRef(null); const modalInitialFocusRef = useRef(null); + const modalDialogRef = useRef(null); + const modalReturnFocusRef = useRef(null); + const modalFallbackFocusRef = useRef(null); + const modalWasOpenRef = useRef(false); const generationDialogRef = useRef(generationDialog); const exitDialogOpenRef = useRef(exitDialogOpen); const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen); @@ -1871,6 +1895,30 @@ export function AssetCanvasSurface({ [captureHistory, markDirty], ); + const deleteSelected = useCallback(() => { + const layerIds = selectedLayerIds.filter((layerId) => + layersRef.current.some((layer) => layer.id === layerId), + ); + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + layerIds.length === 0 + ) { + return; + } + captureHistory({ + type: 'delete-image', + count: layerIds.length, + layerIds, + }); + setLayers( + (current) => + removeCanvasLayers(current, layerIds) as RuntimeCanvasLayer[], + ); + setSelectedLayerIds([]); + markDirty(); + }, [captureHistory, markDirty, selectedLayerIds]); + const saveAsset = useCallback(() => { if ( lifecycleRef.current.kind !== 'canvas.editing' || @@ -2727,12 +2775,87 @@ export function AssetCanvasSurface({ } }, [generationInteractionLocked]); - useEffect(() => { - if (modalOpen) { - modalInitialFocusRef.current?.focus(); + const dismissActiveModal = useCallback(() => { + if (generationDialogRef.current !== null) { + closeGenerationDialog(); + return; } + if (serviceIdentityDialogOpenRef.current) { + if (!serviceIdentityPending) { + setServiceIdentityDialogOpen(false); + } + return; + } + if (exitDialogOpenRef.current && !exitActionPending) { + setExitDialogOpen(false); + } + }, [closeGenerationDialog, exitActionPending, serviceIdentityPending]); + + useLayoutEffect(() => { + if (!modalOpen) { + if (!modalWasOpenRef.current) return; + modalWasOpenRef.current = false; + const returnFocus = modalReturnFocusRef.current; + modalReturnFocusRef.current = null; + const focusTarget = + returnFocus?.isConnected === true + ? returnFocus + : modalFallbackFocusRef.current; + focusTarget?.focus(); + return; + } + + if (!modalWasOpenRef.current) { + const activeElement = document.activeElement; + modalReturnFocusRef.current = + activeElement instanceof HTMLElement && + activeElement !== document.body && + activeElement !== document.documentElement + ? activeElement + : null; + modalWasOpenRef.current = true; + } + (modalInitialFocusRef.current ?? modalDialogRef.current)?.focus(); }, [generationDialog, modalOpen, serviceIdentityDialogOpen]); + useEffect(() => { + if (!modalOpen) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + dismissActiveModal(); + return; + } + if (event.key !== 'Tab') return; + const dialog = modalDialogRef.current; + if (!dialog) return; + const focusable = modalFocusableElements(dialog); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = document.activeElement; + if ( + event.shiftKey && + (activeElement === first || !dialog.contains(activeElement)) + ) { + event.preventDefault(); + last?.focus(); + } else if ( + !event.shiftKey && + (activeElement === last || !dialog.contains(activeElement)) + ) { + event.preventDefault(); + first?.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [dismissActiveModal, modalOpen]); + const failurePresentation = lifecycle.kind === 'canvas.failed' ? assetCanvasFailurePresentation(lifecycle) @@ -2768,6 +2891,7 @@ export function AssetCanvasSurface({
+