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 a43e48470..ef4d53e30 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 @@ -47,6 +47,35 @@ struct DirectTaonierArtAssetIdentity { canvas_project_id: String, } +fn direct_taonier_art_generation_runtime_context( + root: &Path, + output_path: &str, + asset_kind: &str, +) -> Result { + let project_id = read_manifest(&root.join(".agent/manifest.json"))? + .project_id + .trim() + .to_string(); + if project_id.is_empty() { + return Err("直连美术生成缺少项目身份,已拒绝创建平台请求".to_string()); + } + let stage = match output_path { + DIRECT_CODEX_ART_SPEC_ASSET_PATH => "art-spec", + DIRECT_CODEX_BACKGROUND_ASSET_PATH => "game-background", + DIRECT_CODEX_SPRITESHEET_ASSET_PATH => "art-spritesheet", + _ => return Err("直连美术生成请求包含未声明的输出路径".to_string()), + }; + Ok(PlatformArtGenerationRuntimeContext { + agent_id: "direct-codex-art".to_string(), + task_id: format!("direct-codex-art-{stage}"), + session_id: project_id, + run_id: stage.to_string(), + source: "direct-codex".to_string(), + action_id: format!("direct-taonier-{stage}"), + action_fingerprint: format!("direct-taonier-art-v1:{asset_kind}:{stage}"), + }) +} + fn direct_taonier_art_asset_identity( root: &Path, expected_path: &str, @@ -281,11 +310,17 @@ async fn generate_direct_taonier_art_asset_at( asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), }; - let generated = if require_slices { - generate_platform_art_asset_with_required_slices_at(root, prompt, &[], &options).await - } else { - generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await - } + let runtime_context = + direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; + let generated = generate_platform_art_asset_with_runtime_options_at( + root, + prompt, + &[], + &options, + require_slices, + &runtime_context, + ) + .await .map_err(|error| { format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") })?; @@ -375,7 +410,11 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } - emit_direct_game_creator_progress(root, "art.spritesheet", "正在生成核心图集并切分四类运行时素材"); + emit_direct_game_creator_progress( + root, + "art.spritesheet", + "正在生成核心图集并切分四类运行时素材", + ); generate_direct_taonier_art_asset_at( root, prompt, @@ -488,7 +527,10 @@ fn sync_direct_codex_project_outputs_at( } update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?; if outputs_changed || manifest_requires_sync { - let _lock = acquire_project_write_lock(root, "direct-codex.version")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.version", + )?; let version_revision = advance_agent_runtime_project_revision_locked(root)?; append_agent_game_iteration_version_at(root, version_revision)?; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 7cfddbb1b..0c1c99497 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1306,6 +1306,59 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(())) } +/// Generates a platform art asset with a durable request identity owned by a +/// caller outside the legacy Agent Runtime. Once the platform has accepted the +/// request, later invocations only recover that same operation; they never +/// create another billable generation for the same logical asset. +pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, + runtime_context: &PlatformArtGenerationRuntimeContext, +) -> Result { + if require_slices && options.asset_kind != "art-spritesheet" { + return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + } + { + // Direct Codex chat persists the user turn concurrently with the first + // platform-art request. Both operations are short-lived project writes; + // wait for the active writer instead of failing the whole generation on + // the expected startup race. + let recovery_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime.recover", + )?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; + } + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + Some(runtime_context), + ) + .await?; + let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime", + )?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; + advance_agent_runtime_project_revision_locked(root)?; + let generated = if require_slices { + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))? + } else { + commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(()))? + }; + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + )?; + Ok(generated) +} + pub(in crate::agent) async fn request_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -6689,25 +6742,29 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture accepted recovery request"); - if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") { + if request + .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ") + { let body = serde_json::json!({ "data": { - "operationId": "accepted-operation-1", - "status": "completed", - "pollAfterMs": 0, - "result": { - "resource": { - "resourceId": "persisted-resource-1", - "projectId": "persisted-canvas-project", - "imageSrc": server_download_url + "job": { + "operationId": "accepted-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "persisted-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": server_download_url + }, + "warning": { + "code": "unsupported-image-style", + "reason": "已保留可用原图" + }, + "sliceWarning": { + "reason": "测试切片告警" + } }, - "warning": { - "code": "unsupported-image-style", - "reason": "已保留可用原图" - }, - "sliceWarning": { - "reason": "测试切片告警" - } } } }) @@ -6741,14 +6798,10 @@ mod canvas_generation_tests { .expect("reject unexpected accepted recovery request"); } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "accepted-recovery-key" - } - }) - .to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "accepted-recovery-user", + "accepted-recovery-key", + &base_url, ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), @@ -6833,7 +6886,8 @@ mod canvas_generation_tests { 2, "accepted recovery must only poll and download" ); - assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 ")); + assert!(requests[0] + .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ")); assert!(requests[1].starts_with("GET /download.png ")); assert!(requests.iter().all(|request| !request.starts_with("POST "))); assert!(requests diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d17f9e717..2fecc6007 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -131,6 +131,7 @@ import { persistSupervisorChatDraft, readInitialProjectPath, readSupervisorChatDraft, + type ProjectSupervisorComponentProps, type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; @@ -254,6 +255,33 @@ const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v2'; const DIRECT_CODEX_PRODUCT_RUNTIME = true; +const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; + +function directCodexConversationMessageId( + turnId: string, + role: ChatMessage['role'], +) { + return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; +} + +function isPersistableDirectCodexConversationMessage(message: ChatMessage) { + if (!message.runtimeOwned) { + return false; + } + const messageId = message.messageId?.trim() ?? ''; + const roleSuffix = `:${message.role}`; + if ( + !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || + !messageId.endsWith(roleSuffix) + ) { + return false; + } + const turnId = messageId.slice( + DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, + -roleSuffix.length, + ); + return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); +} type GameChatAutoPreviewAuthorization = { afterRevision: number; @@ -579,6 +607,8 @@ type AppProps = { gameChatOnly?: boolean; allowAdvancedExternalEditorConfig?: boolean; initialSupervisorMessage?: string; + playRequest?: ProjectSupervisorComponentProps['playRequest']; + onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -601,6 +631,8 @@ export function App({ gameChatOnly = false, allowAdvancedExternalEditorConfig = false, initialSupervisorMessage = '', + playRequest = null, + onPlayRequestHandled, onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, @@ -678,6 +710,7 @@ export function App({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), }); + const handledPlayRequestRef = useRef(null); function updateClientPreview( nextPreview: LocalPreviewResult | null, @@ -701,9 +734,25 @@ export function App({ ); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); + const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); + + function createDirectCodexConversationTurnId() { + directCodexConversationTurnSequenceRef.current += 1; + let randomId = ''; + try { + randomId = globalThis.crypto?.randomUUID?.().trim() ?? ''; + } catch { + // The timestamp and in-page sequence remain unique enough for a local + // conversation append when WebView crypto is unavailable. + } + if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) { + return randomId; + } + return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; + } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -931,9 +980,9 @@ export function App({ ) => Promise) | null >(null); - const executeChatAgentReplyRef = useRef<(prompt: string) => Promise>( - async () => undefined, - ); + const executeChatAgentReplyRef = useRef< + (prompt: string, directConversationTurnId?: string) => Promise + >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -2107,7 +2156,8 @@ export function App({ projectSupervisorOnly && pendingMessages.every( (message) => - message.runtimeOwned || + (message.runtimeOwned && + !isPersistableDirectCodexConversationMessage(message)) || isTransientProjectOpenMessage(message, nextProjectPath), ) ) { @@ -2170,7 +2220,10 @@ export function App({ void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { - if (message.runtimeOwned) { + if ( + message.runtimeOwned && + !isPersistableDirectCodexConversationMessage(message) + ) { savedConversationCountRef.current = start + index + 1; continue; } @@ -2704,6 +2757,30 @@ export function App({ ]); } + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if ( + !projectSupervisorOnly || + !playRequest || + !nextProjectPath || + playRequest.projectPath !== nextProjectPath + ) { + return; + } + const requestKey = `${playRequest.projectPath}\n${playRequest.requestId}`; + if (handledPlayRequestRef.current === requestKey) { + return; + } + handledPlayRequestRef.current = requestKey; + onPlayRequestHandled?.(playRequest.requestId); + queueRunLocalShortcut(); + }, [ + localProject?.projectPath, + onPlayRequestHandled, + playRequest, + projectSupervisorOnly, + ]); + function queueStaticSmokeShortcut() { if (!requireChatProjectForUserAction()) { return; @@ -5874,7 +5951,10 @@ export function App({ } } - async function executeChatAgentReply(prompt: string) { + async function executeChatAgentReply( + prompt: string, + directConversationTurnId?: string, + ) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -5904,6 +5984,35 @@ export function App({ } } if (directProjectPath && directInvoke) { + const directAssistantMessageId = directConversationTurnId + ? directCodexConversationMessageId( + directConversationTurnId, + 'assistant', + ) + : undefined; + const appendDirectUserMessageIfMissing = ( + current: ChatMessage[], + ): ChatMessage[] => { + if (!directConversationTurnId) { + return current; + } + const directUserMessageId = directCodexConversationMessageId( + directConversationTurnId, + 'user', + ); + return current.some((message) => message.messageId === directUserMessageId) + ? current + : [ + ...current, + { + role: 'user' as const, + text: prompt, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + }, + ]; + }; setChatAgentBusy(true); setDirectCodexProgress('已提交需求,正在准备智能创作'); setProjectSupervisorRuntimeError(''); @@ -5917,11 +6026,14 @@ export function App({ ); if (localProjectPathRef.current === directProjectPath) { setMessages((current) => [ - ...current, + ...appendDirectUserMessageIfMissing(current), { role: 'assistant', text: reply, runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), updatedAt: Date.now(), }, ]); @@ -5939,11 +6051,14 @@ export function App({ if (localProjectPathRef.current === directProjectPath) { setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => [ - ...current, + ...appendDirectUserMessageIfMissing(current), { role: 'assistant', text: visibleMessage, runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), updatedAt: Date.now(), }, ]); @@ -5964,6 +6079,14 @@ export function App({ role: 'assistant', text: message, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'assistant', + ), + } + : {}), updatedAt: Date.now(), }, ]); @@ -6182,16 +6305,27 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; + const directConversationTurnId = directCodexProductRuntime + ? createDirectCodexConversationTurnId() + : undefined; setMessages((current) => [ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'user', + ), + } + : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current(latch.prompt); + void executeChatAgentReplyRef.current(latch.prompt, directConversationTurnId); }, [ chatAgentBusy, directCodexProductRuntime, @@ -11175,6 +11309,9 @@ export function App({ if (supervisorChatOnly || gameChatOnly) { supervisorChatShouldFollowLatestRef.current = true; } + const directConversationTurnId = directCodexProductRuntime + ? createDirectCodexConversationTurnId() + : undefined; setChatInput(''); setMessages((current) => [ ...current, @@ -11182,10 +11319,18 @@ export function App({ role: 'user', text: prompt, runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'user', + ), + } + : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReply(prompt); + void executeChatAgentReply(prompt, directConversationTurnId); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -11311,14 +11456,18 @@ export function App({ directCodexProductRuntime ? false : projectSupervisorNeedsUserInput } onCancelConfirmation={cancelUiCommandConfirmation} + onCancelPendingCommand={handlePendingCommandCancel} onChatInputChange={setChatInput} onConfirmConfirmation={confirmUiCommand} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} onScroll={handleConversationScroll} onShowEarlierMessages={showEarlierConversationMessages} onSubmit={handleProjectSupervisorOnlySubmit} pendingConfirmation={ directCodexProductRuntime ? null : pendingUiConfirmation } + pendingCommand={directCodexProductRuntime ? pendingCommand : null} + projectPath={localProject?.projectPath ?? projectPath} transientReply={ directCodexProductRuntime ? directCodexProgress diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1c0bbdca7..8409657f3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1469,13 +1469,41 @@ export function registerHomeProjectCreationTests() { ([command]) => command === 'resume_game_creator_agent_runtime_tasks', ), ).toHaveLength(0); + const directConversationAppends = invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + (args as Record)?.agentId === null, + ); + expect(directConversationAppends).toHaveLength(2); + const directConversationMessages = directConversationAppends.map( + ([, args]) => args as Record, + ); + expect(directConversationMessages.map((args) => args.message)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.stringContaining('初始意图:art / 做素材'), + }), + expect.objectContaining({ + role: 'assistant', + content: 'DIRECT_CODEX_TEST_OK', + }), + ]), + ); + const directConversationMessageIds = directConversationMessages.map((args) => + String(args.messageId), + ); + expect(directConversationMessageIds).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^direct-codex:[a-z0-9-]+:user$/iu), + expect.stringMatching(/^direct-codex:[a-z0-9-]+:assistant$/iu), + ]), + ); expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record)?.agentId === null, - ), - ).toHaveLength(0); + directConversationMessageIds[0].replace(/:(?:user|assistant)$/u, ''), + ).toBe( + directConversationMessageIds[1].replace(/:(?:user|assistant)$/u, ''), + ); expect(invoke).not.toHaveBeenCalledWith( 'generate_local_game_draft', expect.anything(), @@ -1549,15 +1577,45 @@ export function registerHomeProjectCreationTests() { 'failed-direct-project', '直连失败项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { expect(args).toEqual({ projectPath }); return manifest; } + if (command === 'read_local_conversation') { + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } if (command === 'append_local_permission_log') { return {}; } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as Record; + persistedMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + messageId: String(args?.messageId ?? ''), + updatedAt: Number(message.updatedAt ?? persistedMessages.length + 1), + }); + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } if (command === 'chat_with_game_creator_direct_codex') { throw new Error('codex-app-server-error:unauthorized'); } @@ -1578,6 +1636,39 @@ export function registerHomeProjectCreationTests() { expect( await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'), ).not.toBeNull(); + await waitFor(() => { + expect(persistedMessages).toHaveLength(2); + }); + expect(persistedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '生成一个游戏' }), + expect.objectContaining({ + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', + }), + ]), + ); + expect(JSON.stringify(persistedMessages)).not.toContain( + 'codex-app-server-error:unauthorized', + ); + cleanup(); + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + expect(await screen.findByText('生成一个游戏')).not.toBeNull(); + expect( + await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'), + ).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 46b3a5e37..543f98cb1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -734,6 +734,10 @@ export function registerProjectWorkbenchFoundationTests() { }) as HTMLButtonElement; expect(runTab.disabled).toBe(false); expect(runTab.getAttribute('data-unavailable')).toBe('true'); + const playButton = screen.getByRole('button', { + name: '播放', + }) as HTMLButtonElement; + expect(playButton.disabled).toBe(true); fireEvent.click(runTab); expect(runTab.getAttribute('aria-selected')).toBe('false'); expect( @@ -3524,6 +3528,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ); window.__TAURI__ = { core: { invoke } }; + const onPlay = vi.fn(); render( React.createElement(ProjectDevelopmentView, { @@ -3536,9 +3541,14 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), + onPlay, }), ); + fireEvent.click(screen.getByRole('button', { name: '播放' })); + expect(onPlay).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('运行表现层')).not.toBeNull(); + const runTab = screen.getByRole('tab', { name: '运行', }) as HTMLButtonElement; @@ -3661,6 +3671,10 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('tab', { name: '运行' })); expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull(); expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull(); + expect( + screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'), + ).not.toBeNull(); + expect(screen.queryByText(/\/run|\/preview/)).toBeNull(); }); } @@ -3913,6 +3927,55 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('routes a top workbench play request into the existing game.run_local confirmation', async () => { + const projectPath = '/tmp/top-play-request'; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '顶部播放请求', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + const handled = vi.fn(); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + playRequest: { projectPath, requestId: 7 }, + onPlayRequestHandled: handled, + }), + ); + + expect( + await screen.findByText('game.run_local', {}, { timeout: 3_000 }), + ).not.toBeNull(); + expect(handled).toHaveBeenCalledWith(7); + expect( + screen.getByText(`运行自检并启动 ${projectPath}/game/`), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + }); + it('keeps the direct Codex welcome surface free of legacy Supervisor status before the first message', async () => { const projectPath = '/tmp/launcher-empty-supervisor-game'; const manifest = createGameCreationAppManifest( @@ -9235,7 +9298,7 @@ export function registerProjectSupervisorSurfaceTests() { invoke.mock.calls.filter( ([command]) => command === 'read_project_permission_policy', ), - ).toHaveLength(policyReadCountBeforeChat); + ).toHaveLength(policyReadCountBeforeChat + 1); }); it('keeps legacy professional Agent results readable while runtime controls stay hidden', async () => { diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 73d31e3b2..1f628a6a8 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -133,3 +133,89 @@ 1. direct Runtime 在不输出内部路径、Provider 或工具细节的前提下,复用 `game-creator-agent-progress` 发送安全的公开阶段:需求已接收、检查陶泥儿美术包、规范图、背景图、图集与切片、代码生成、项目版本登记和预览刷新。普通直连工作台按当前项目路径接收并即时显示该阶段;命令失败仍沿用现有安全错误映射,不把阶段进度伪装成已完成。 2. 资源管理首次进入时主动请求最多 12 个可预览的非音频、非版本、非占位资源。请求仍服从三并发、96 项队列、48 项/64 MiB 缓存和原有身份失效规则;后续资源继续通过 `IntersectionObserver` 按滚动加载,详情与播放请求可提升优先级。首屏预取不改变权限策略,读取被拒绝时卡片必须保留安全错误状态。 3. 验收必须同时证明:提交直连需求后立即可见“已提交需求,正在准备智能创作”,收到阶段事件后显示对应用户文案;真实普通 AGC 工作台首次打开既有项目时,无需进入详情即可显示游戏代码摘要以及陶泥儿规范图、场景背景图和核心图集的缩略图。 + +## 10. 平台已完成图集结果回收修复(2026-08-17) + +### 现场证据 + +- 空项目的规范图与场景背景图已通过当前平台登录态生成、登记;核心 `grid-2x2` 图集任务在平台侧进入完成态后,客户端显示“美术包生成失败”。 +- 失败文本表明客户端收到了 `completed`,却没有拿到可消费的 `result`。这不是未登录、未配置平台生图或背景图失败,也不能通过重新提交图集来处理,否则会造成重复扣点。 + +### 修复计划 + +1. 核对平台账号模式的 `/api/runtime/external-generation/jobs/{operationId}` 完成响应、通用外部 API 完成响应和持久化 `result_payload_json` 的真实包络;统一把完成结果映射为客户端可消费的 `result`,保持 `grid-2x2`、四类切片、资源和资产身份合同不变。 +2. 对已进入 `accepted/running/completed` 的本地图集账本只轮询并恢复同一个远端任务;仅 `prepared` 且未取得接受凭证时才以原幂等键重放。任何“结果暂不可读”都保留账本并进入可恢复状态,不发起新的付费请求。 +3. 后端增加 completed 队列结果的契约测试;原生端增加平台 completed 包络的回收、四切片严格验收和不重提回归;前端继续将内部错误转换为安全、可行动的中文提示。 +4. 先对现有失败项目执行同一任务的状态恢复,确认不会产生新的生成提交;再用当前客户端新建项目跑完整规范图、背景图、图集、四切片、代码、版本与预览闭环。真实验收未通过前,不把本次修复标记为完成。 + +## 11. 直连聊天记录持久化(2026-08-17) + +### 问题 + +- 普通直连创作会把用户输入、最终回复和失败提示都标记为运行时消息;项目对话保存器此前统一跳过这类消息。 +- 项目重新打开时已经会读取 `.agent/conversations/project.jsonl`,但直连问答从未写入该文件,因此客户端重启后只剩默认欢迎语。 + +### 现行契约 + +1. 每次直连提交生成一个仅用于项目对话的稳定回合标识,并把最终用户消息与最终助手消息分别写为 `direct-codex::user`、`direct-codex::assistant`。保存命令继续以 `messageId` 幂等,重试不得产生重复记录。 +2. 仅上述 role 与 ID 匹配的最终直连问答可以穿过运行时消息过滤并写入项目主对话;阶段进度、预览启动状态、流式中间内容及无稳定回合 ID 的运行时提示仍只保留在当前页面。 +3. 直连失败时,只保存已通过 `projectRuntimeVisibleError(...)` 转换的安全中文提示,禁止把 app-server、Provider、路径、认证或工具原始错误写入对话文件。 +4. 项目重新打开时继续复用既有主对话读取与 hydration。恢复聊天记录不代表 Codex app-server 的进程内 thread 能跨客户端恢复;下一条消息仍按现有直连会话策略发起。 + +### 验收 + +- AppSurface 覆盖成功与失败回合:用户消息和最终回复各保存一次、共享同一 turn 的不同 role ID;失败记录不含原始内部错误。 +- 重载同一项目时,`read_local_conversation` 返回已保存记录,聊天区恢复展示,不再次提交直连请求。 +- 真实桌面端验收:在同一项目完成一条直连问答,关闭并重新打开当前客户端后,用户输入和最终可见回复仍在项目聊天中。 + +### 并发写入约束 + +直连消息保存与首轮陶泥儿平台美术生成可能同时触发项目写锁。两者均为短时本地写入,直连美术生成的恢复锁、提交锁以及生成产物版本登记必须使用现有的有界等待锁;不能因为聊天记录保存占用项目锁的瞬时竞态而终止整轮游戏生成。 + +## 12. 顶部播放入口(2026-08-17) + +### 产品边界 + +1. 项目工作台顶部提供唯一面向普通用户的“播放”按钮;运行空态不再引导用户输入 `/run` 或 `/preview`。 +2. 播放按钮只负责进入运行视图并排入现有 `game.run_local` 确认流程,不新增一套预览启动实现,也不直接拼接或打开外部 URL。 +3. 现有聊天命令解析暂时保留为兼容入口和回归测试依据,但不再作为工作台产品引导。 + +### 运行合同 + +- 点击播放后仍需经过项目权限确认。 +- 确认后继续执行 `game.static_smoke`、`start_local_game_preview`、预览状态同步、manifest / run trace 刷新和 loopback iframe 安全校验。 +- 没有可运行原型时按钮禁用;运行视图仍由现有 `runAvailable` 判定控制。 + +### 验收 + +- 可运行项目的工作台顶部显示“播放”,点击后进入运行视图并排入 `game.run_local`。 +- 没有可运行原型时“播放”禁用。 +- 运行空态文案只提示点击顶部播放按钮,不出现 `/run` 或 `/preview`。 + +## 13. 直连 Codex 的 LLM 自主试玩验收与柔性美术合同(2026-08-17) + +### 13.1 分层边界 + +直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。 + +`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。 + +### 13.2 同一 Codex thread 的有界自主验收 + +每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness: + +1. 首轮由 Codex 生成或修改当前工作区文件。 +2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 +3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 + +最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。 + +### 13.3 最低使用证明与完成条件 + +客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 + +### 13.4 验收要求 + +- 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。 +- fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。 +- 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。