diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 9aeaf988b..44135c949 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -51,7 +51,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | --- | --- | --- | --- | | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | -| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -92,6 +92,8 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. +`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets. + ## Common Values Use OpenAPI as the final authority; these common values are a routing aid: diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 855cc0f16..21f881bc0 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -78,9 +78,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop. -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 0b20e28e2..f56925002 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -69,6 +69,9 @@ jobs: - name: Install npm dependencies run: bash scripts/ci-npm-ci-with-retry.sh + - name: Install AI game creator dependencies + run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell + - name: Run repository checks run: npm run check:repository-ci diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 2b3f24be6..4dd14447d 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -2,11 +2,11 @@ "agentMode": "codex_app_server", "llm": { "apiKey": "", - "baseUrl": "https://api.openai.com/v1", - "model": "gpt-4.1", + "baseUrl": "https://dev.genarrative.world/gpt/v1", + "model": "gpt-5.6-sol", "apiKind": "openai_responses", - "reasoningEffort": "high", - "stream": false, + "reasoningEffort": "max", + "stream": true, "webSearchEnabled": false, "contextWindowTokens": 128000, "autoCompactTokenLimit": 64000, @@ -16,10 +16,6 @@ "retryBackoffMs": 500 }, "agentLlm": {}, - "editorApi": { - "baseUrl": "http://127.0.0.1:8082", - "apiKey": "" - }, "mcpServers": {}, "planning": { "capabilityEnabled": true diff --git a/apps/ai-game-creator-shell/index.html b/apps/ai-game-creator-shell/index.html index fa69c5471..9de1d37b8 100644 --- a/apps/ai-game-creator-shell/index.html +++ b/apps/ai-game-creator-shell/index.html @@ -4,7 +4,7 @@ - AI 游戏创作 + 陶泥儿
diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 5ad694034..69bd74590 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -24,6 +24,7 @@ "agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat", "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense", + "agent-runtime:supervisor-game-chat-single-main-playable-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index 0bf11ca70..89f552dc7 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { createDeterministicLaneDefenseRouter, + deterministicLaneDefenseCanonicalHtml, deterministicLaneDefenseInitialHtml, deterministicLaneDefenseModel, deterministicManifestReadyAgentIds, @@ -50,6 +51,32 @@ function parseArguments(args) { return { keepProject, selfTest }; } +function deterministicRuntimeConfig(apiKey, provider) { + return { + agentMode: 'provider', + editorApi: { + apiKey, + baseUrl: provider.editorBaseUrl, + }, + llm: { + apiKey, + baseUrl: provider.baseUrl, + model: deterministicLaneDefenseModel, + apiKind: 'openai_chat', + reasoningEffort: 'max', + stream: false, + requestTimeoutMs: 30_000, + maxRetries: 0, + retryBackoffMs: 100, + }, + agentLlm: { + 'project-supervisor': { + reasoningEffort: 'max', + }, + }, + }; +} + function appendBounded(current, chunk) { const combined = Buffer.concat([current, chunk]); if (combined.length > outputLimit) throw new Error('child-output-too-large'); @@ -117,71 +144,14 @@ function parseChildReport(result) { } } -function responsibilityContractEvidence(stats) { - const qualityPlanningCount = - stats?.byAgent?.['quality-review']?.planning ?? null; - const evidence = { - delegationContractCount: stats?.delegationContractCount ?? null, - delegationContractViolationCount: - stats?.delegationContractViolationCount ?? null, - codePrototypeDelegationCount: stats?.codePrototypeDelegationCount ?? null, - codePrototypeExpectedArtifactCount: - stats?.codePrototypeExpectedArtifactCount ?? null, - codePrototypeGameIndexArtifactDelegationCount: - stats?.codePrototypeGameIndexArtifactDelegationCount ?? null, - codePrototypeProjectMutationCount: - stats?.byAgent?.['code-prototype']?.projectMutation ?? null, - qualityReviewDelegationCount: stats?.qualityReviewDelegationCount ?? null, - qualityReviewReadOnlyDelegationCount: - stats?.qualityReviewReadOnlyDelegationCount ?? null, - qualityReviewExpectedArtifactCount: - stats?.qualityReviewExpectedArtifactCount ?? null, - artAssetPlanDelegationCount: stats?.artAssetPlanDelegationCount ?? null, - artAssetPlanExpectedArtifactCount: - stats?.artAssetPlanExpectedArtifactCount ?? null, - artAssetPlanArtifactContractCount: - stats?.artAssetPlanArtifactContractCount ?? null, - qualityReviewProjectMutationCount: - stats?.byAgent?.['quality-review']?.projectMutation ?? null, - qualityPlanningCount, - qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null, - }; - return { - ...evidence, - codeOwnsGameIndex: - Number.isInteger(evidence.codePrototypeDelegationCount) && - evidence.codePrototypeDelegationCount >= 1 && - evidence.codePrototypeExpectedArtifactCount === - evidence.codePrototypeDelegationCount && - evidence.codePrototypeGameIndexArtifactDelegationCount === - evidence.codePrototypeDelegationCount && - Number.isInteger(evidence.codePrototypeProjectMutationCount) && - evidence.codePrototypeProjectMutationCount >= 2, - qualityIsReadOnly: - evidence.qualityReviewDelegationCount === 1 && - evidence.qualityReviewReadOnlyDelegationCount === 1 && - evidence.qualityReviewExpectedArtifactCount === 0 && - evidence.qualityReviewProjectMutationCount === 0, - qualityIndependentOfCodeTiming: - Number.isInteger(qualityPlanningCount) && - qualityPlanningCount >= 1 && - Number.isInteger(evidence.qualityRevisionReplanCount) && - evidence.qualityRevisionReplanCount >= 0 && - evidence.qualityRevisionReplanCount <= 1, - artOwnsGeneratedAsset: - evidence.artAssetPlanDelegationCount === 1 && - evidence.artAssetPlanExpectedArtifactCount === 2 && - evidence.artAssetPlanArtifactContractCount === 1, - contractViolationFree: - Number.isInteger(evidence.delegationContractCount) && - evidence.delegationContractCount >= 3 && - evidence.delegationContractViolationCount === 0, - }; -} - function expectedProviderStats(stats) { - const responsibility = responsibilityContractEvidence(stats); const manifestReadyTasks = manifestReadyTaskEvidence(stats); + const projectSupervisorRuntimePlanningCount = + Number.isInteger(stats.byAgent?.['project-supervisor']?.planning) && + Number.isInteger(stats.interactionExecuteCount) + ? stats.byAgent['project-supervisor'].planning - + stats.interactionExecuteCount + : null; return ( Number.isInteger(stats.requestCount) && stats.requestCount >= 40 && @@ -191,38 +161,40 @@ function expectedProviderStats(stats) { (stats.imageInspectionRequestCount ?? 0) === stats.requestCount && stats.finalReplyRequestCount === 0 && - stats.initialDelegationCount === 3 && - stats.followupDelegationCount === 0 && + stats.interactionExecuteCount === 1 && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.delegateActionCount === 0 && stats.runStatusCount >= 1 && stats.sourceWriteCount >= 7 && stats.staticSmokeCount >= 4 && stats.previewValidationCount >= 2 && - stats.supervisorDirectMutationAttemptCount === 0 && manifestReadyTasks.exactlyOnce && stats.manifestReadyTaskFileWriteCount >= 7 && stats.manifestReadyTaskPreviewValidationCount >= 1 && - stats.manifestReadyTaskCanvasGenerationCount >= 1 && - stats.manifestReadyTaskCanvasGenerationCount <= 2 && + stats.manifestReadyTaskCanvasGenerationCount >= + stats.canvasGenerationRequestCount && + stats.manifestReadyTaskCanvasGenerationCount <= + stats.canvasGenerationRequestCount + 16 && stats.imageInspectionRequestCount === 1 && - stats.canvasGenerationRequestCount === 2 && - stats.canvasDownloadRequestCount === 2 && + stats.canvasGenerationRequestCount === 3 && + stats.canvasDownloadRequestCount === 3 && + stats.canvasSliceDownloadRequestCount === 4 && stats.canvasGeneratedAspectRatios?.['16:9'] === 1 && - stats.canvasGeneratedAspectRatios?.['1:1'] === 1 && + stats.canvasGeneratedAspectRatios?.['1:1'] === 2 && stats.canonicalCodeRunCount === 1 && + stats.canonicalCodeAssetListCount === 1 && stats.unexpectedRequestCount === 0 && Object.keys(stats.rejectionCodes ?? {}).length === 0 && - stats.byAgent?.['project-supervisor']?.planning >= 5 && + projectSupervisorRuntimePlanningCount >= 5 && stats.byAgent?.['project-supervisor']?.finalReply === 0 && stats.byAgent?.['project-supervisor']?.projectMutation === 0 && - stats.byAgent?.['code-prototype']?.planning >= 6 && + stats.byAgent?.['code-prototype']?.planning >= 5 && stats.byAgent?.['code-prototype']?.finalReply === 0 && - stats.byAgent?.['code-prototype']?.projectMutation >= 2 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && stats.byAgent?.['quality-review']?.finalReply === 0 && - responsibility.codeOwnsGameIndex && - responsibility.qualityIsReadOnly && - responsibility.qualityIndependentOfCodeTiming && - responsibility.artOwnsGeneratedAsset && - responsibility.contractViolationFree + stats.byAgent?.['quality-review']?.projectMutation === 0 ); } @@ -362,6 +334,53 @@ function syntheticPayload(agentId, runId, tools, extraContext = '') { }; } +function syntheticInteractionExecutePayload() { + return { + model: deterministicLaneDefenseModel, + stream: false, + reasoning_effort: 'max', + messages: [ + { + role: 'system', + content: '你现在位于统一的 Agent interaction loop。', + }, + { + role: 'user', + content: '用户这轮输入:\n制作一个三消经营游戏', + }, + ], + tools: ['runtime_execute', 'runtime_resume', 'project_location'].map( + (name) => ({ type: 'function', function: { name } }), + ), + }; +} + +function syntheticUiPrototypeInspectionPayload() { + return { + model: deterministicLaneDefenseModel, + stream: false, + messages: [ + { + role: 'system', + content: '你是游戏界面视觉检查 Agent。只分析画面。', + }, + { + role: 'user', + content: [ + { + type: 'text', + text: '请逐项检查 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme,并只返回严格 JSON。', + }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,AA==' }, + }, + ], + }, + ], + }; +} + function responseFunctionNames(response) { return response.choices[0].message.tool_calls.map( (call) => call.function.name, @@ -387,12 +406,49 @@ async function runSelfTest() { html.includes('requestAnimationFrame'), 'self-test-html-contract-invalid', ); + const canonicalHtml = deterministicLaneDefenseCanonicalHtml(); + assert( + canonicalHtml.includes(visibleCanvasCss) && + canonicalHtml.includes("atlasArt.src='../assets/art-spritesheet.png'") && + [ + 'player.png', + 'blocks-and-targets.png', + 'obstacles-and-scene.png', + 'feedback-effects.png', + ].every((fileName) => + canonicalHtml.includes(`../assets/art-spritesheet-slices/${fileName}`), + ) && + (canonicalHtml.match(/ctx\.drawImage\(/g) ?? []).length >= 5, + 'self-test-canonical-visible-art-contract-invalid', + ); const apiKey = `deterministic-self-test-${randomUUID()}`; - const router = createDeterministicLaneDefenseRouter({ apiKey }); + const runtimeConfig = deterministicRuntimeConfig(apiKey, { + baseUrl: 'http://127.0.0.1:41001/v1', + editorBaseUrl: 'http://127.0.0.1:41002', + }); + assert( + runtimeConfig.agentMode === 'provider' && + runtimeConfig.llm.apiKind === 'openai_chat' && + runtimeConfig.llm.reasoningEffort === 'max' && + runtimeConfig.agentLlm?.['project-supervisor']?.reasoningEffort === + 'max' && + runtimeConfig.llm.apiKey === apiKey && + runtimeConfig.editorApi.apiKey === apiKey, + 'self-test-provider-runtime-config-invalid', + ); + const captureProviderErrorCode = (operation) => { + try { + operation(); + return null; + } catch (error) { + return error?.code ?? null; + } + }; const allTools = [ 'update_agent_plan', 'respond_to_user', - 'runtime_tool_agent_delegate', + 'runtime_tool_agent_action_history', + 'runtime_tool_agent_acceptance_update', 'runtime_tool_agent_run_status', 'runtime_tool_command_run_limited', 'runtime_tool_file_patch', @@ -405,141 +461,155 @@ async function runSelfTest() { 'runtime_tool_asset_list', 'runtime_tool_file_read', ]; + const rootRunId = 'parent-run'; + const contractFingerprint = 'a'.repeat(64); + const contractContext = `[Root Goal Contract]\n${JSON.stringify({ contractFingerprint })}`; + const observationContext = (observations) => + `${contractContext}\n已有工具观察:\n${JSON.stringify(observations)}`; + const goalContractTools = ['runtime_tool_agent_goal_contract']; + const goalContractRouter = createDeterministicLaneDefenseRouter({ apiKey }); + goalContractRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'goal-contract-order-run', + goalContractTools, + ), + }); + assert( + captureProviderErrorCode(() => + goalContractRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'goal-contract-order-run', + goalContractTools, + ), + }), + ) === 'provider-goal-contract-out-of-order', + 'self-test-goal-contract-duplicate-not-rejected', + ); + + const router = createDeterministicLaneDefenseRouter({ apiKey }); const route = (agentId, runId, tools = allTools, extraContext = '') => router.route({ authorization: `Bearer ${apiKey}`, payload: syntheticPayload(agentId, runId, tools, extraContext), }); - const initialDelegationResponse = route('project-supervisor', 'parent-run'); - const initialDelegationCalls = responseFunctionCalls( - initialDelegationResponse, + const interactionExecuteResponse = router.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticInteractionExecutePayload(), + }); + const interactionExecuteCalls = responseFunctionCalls( + interactionExecuteResponse, + ); + const interactionExecuteStats = router.getStats(); + assert( + interactionExecuteCalls.length === 1 && + interactionExecuteCalls[0]?.name === 'runtime_execute' && + Object.keys(interactionExecuteCalls[0]?.arguments ?? {}).length === 0 && + interactionExecuteStats.requestCount === 1 && + interactionExecuteStats.planningRequestCount === 1 && + interactionExecuteStats.interactionExecuteCount === 1 && + interactionExecuteStats.manifestReadyTaskRunCount === 0 && + interactionExecuteStats.manifestReadyTaskCompletionCount === 0 && + Object.keys(interactionExecuteStats.readyTaskCountsByAgent).length === 0, + 'self-test-interaction-max-execute-sequence-invalid', + ); + const visualInspectionResponse = router.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticUiPrototypeInspectionPayload(), + }); + const visualInspection = JSON.parse( + visualInspectionResponse.choices[0].message.content, ); assert( - initialDelegationCalls.map((call) => call.name).join(',') === - 'runtime_tool_agent_delegate,runtime_tool_agent_delegate,runtime_tool_agent_delegate', - 'self-test-initial-delegation-invalid', + Object.keys(visualInspection?.checks ?? {}).join(',') === + 'informationHud,gameplaySurface,objectiveEntities,primaryControls,failureRestartFlow,responsiveLayout,implementationClarity,originalTheme' && + Object.values(visualInspection.checks).every((value) => value === true) && + Array.isArray(visualInspection.issues) && + visualInspection.issues.length === 0 && + router.getStats().imageInspectionRequestCount === 1 && + router.getStats().unexpectedRequestCount === 0, + 'self-test-ui-prototype-inspection-request-not-routed', ); - const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input; - const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input; - const initialArtDelegation = initialDelegationCalls[2]?.arguments?.input; - assert( - initialCodeDelegation?.agentId === 'code-prototype' && - JSON.stringify(initialCodeDelegation.expectedArtifacts) === - JSON.stringify(['game/index.html']) && - initialQualityDelegation?.agentId === 'quality-review' && - initialQualityDelegation.task.includes('只读验收') && - initialQualityDelegation.task.includes('不要修改任何文件') && - initialQualityDelegation.task.includes( - '不要读取或依赖并行 code-prototype', - ) && - Array.isArray(initialQualityDelegation.expectedArtifacts) && - initialQualityDelegation.expectedArtifacts.length === 0 && - initialArtDelegation?.agentId === 'art-asset-plan' && - initialArtDelegation.task.includes('canvas.asset_generate') && - JSON.stringify(initialArtDelegation.expectedArtifacts) === - JSON.stringify([ - 'assets/manifest.art.json', - 'assets/art-spritesheet.png', - ]), - 'self-test-initial-delegation-contract-invalid', - ); - const qualityPlan = - '计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无'; - const builderPlan = - '计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无'; - assert( - responseFunctionNames( - route('quality-review', 'quality-run', allTools, qualityPlan), - ).join(',') === 'update_agent_plan,respond_to_user', - 'self-test-quality-read-only-invalid', - ); - route('code-prototype', 'initial-code-run'); - route('code-prototype', 'initial-code-run'); - assert( - responseFunctionNames( - route('code-prototype', 'initial-code-run', allTools, builderPlan), - ).join(',') === 'update_agent_plan,respond_to_user', - 'self-test-builder-plan-completion-invalid', - ); - assert( - responseFunctionNames( - route( - 'quality-review', - 'quality-run', - allTools, - `${qualityPlan}\n已有工具观察:\n${JSON.stringify([ - { - tool: 'runtime.verification', - status: 'blocked', - summary: '最终回复基于的项目 revision 已过期', - detail: 'responseRevision=0, currentRevision=1', - }, - ])}`, - ), - ).join(',') === 'update_agent_plan,respond_to_user', - 'self-test-quality-stale-replan-invalid', - ); - route('project-supervisor', 'parent-run'); - route('project-supervisor', 'parent-run'); - route('project-supervisor', 'parent-run'); - assert( - responseFunctionNames(route('project-supervisor', 'parent-run'))[0] === - 'runtime_tool_file_patch', - 'self-test-forbidden-parent-mutation-invalid', - ); - const followupDelegationResponse = route( + const goalContractResponse = route( 'project-supervisor', - 'parent-run', - ['runtime_tool_agent_delegate'], - '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate', + rootRunId, + goalContractTools, ); - const followupDelegationCall = responseFunctionCalls( - followupDelegationResponse, - )[0]; + const goalContractCalls = responseFunctionCalls(goalContractResponse); + const acceptanceNodes = + goalContractCalls[0]?.arguments?.input?.acceptanceNodes ?? []; assert( - followupDelegationCall?.name === 'runtime_tool_agent_delegate' && - followupDelegationCall.arguments?.input?.agentId === 'code-prototype' && - JSON.stringify( - followupDelegationCall.arguments.input.expectedArtifacts, - ) === JSON.stringify(['game/index.html']), - 'self-test-followup-delegation-invalid', - ); - route('code-prototype', 'repair-code-run'); - route('code-prototype', 'repair-code-run'); - route('code-prototype', 'repair-code-run', allTools, builderPlan); - assert( - responseFunctionNames(route('project-supervisor', 'parent-run'))[0] === - 'runtime_tool_agent_run_status', - 'self-test-repair-claim-before-verification-invalid', - ); - assert( - responseFunctionNames(route('project-supervisor', 'parent-run')).join( - ',', - ) === 'runtime_tool_task_list,runtime_tool_agent_run_status', - 'self-test-manifest-wait-invalid', + goalContractCalls.length === 1 && + goalContractCalls[0]?.name === 'runtime_tool_agent_goal_contract' && + goalContractCalls[0]?.arguments?.input?.outcome?.includes( + '可完整游玩的原创植物塔防网页游戏', + ) && + acceptanceNodes.length === 2 && + acceptanceNodes[0]?.criterionId === 'static-current-revision' && + JSON.stringify(acceptanceNodes[0]?.requiredEvidence) === + JSON.stringify(['tool:command.run_limited']) && + acceptanceNodes[1]?.criterionId === 'playable-current-revision' && + JSON.stringify(acceptanceNodes[1]?.requiredEvidence) === + JSON.stringify(['tool:preview.validate']) && + JSON.stringify(acceptanceNodes[1]?.dependsOn) === + JSON.stringify(['static-current-revision']), + 'self-test-goal-contract-invalid', ); assert( responseFunctionNames( route( 'project-supervisor', - 'parent-run', - allTools, - '已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16', + rootRunId, + ['runtime_tool_task_list', 'runtime_tool_agent_run_status'], + `${contractContext}\n当前正式 manifest DAG 仍有专业 task 在运行`, ), - ).join(',') === - 'runtime_tool_command_run_limited,runtime_tool_preview_validate', - 'self-test-repair-verification-batch-invalid', + ).join(',') === 'runtime_tool_task_list,runtime_tool_agent_run_status', + 'self-test-manifest-wait-invalid', ); - route('project-supervisor', 'parent-run'); + const visualReadyAgentIds = new Set([ + 'art-director', + 'design-foundation', + 'art-asset-plan', + ]); + const canvasCallsByAgent = new Map(); + const callBatchesByAgent = new Map(); for (const agentId of deterministicManifestReadyAgentIds) { const runId = `manifest-ready-${agentId}`; const extraContext = `处理 manifest ready 任务:${agentId}\n` + `任务 ID:${agentId}`; + const readyTools = visualReadyAgentIds.has(agentId) + ? [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + 'runtime_tool_image_inspect', + ] + : manifestReadyTools; let terminalCompletionCount = 0; for (let attempt = 0; attempt < 8; attempt += 1) { - const response = route(agentId, runId, manifestReadyTools, extraContext); - if (responseFunctionNames(response).includes('respond_to_user')) { + const attemptTools = + agentId === 'art-director' && attempt >= 2 + ? ['respond_to_user'] + : readyTools; + const response = route(agentId, runId, attemptTools, extraContext); + const calls = responseFunctionCalls(response); + const batches = callBatchesByAgent.get(agentId) ?? []; + batches.push(calls.map((call) => call.name)); + callBatchesByAgent.set(agentId, batches); + const canvasCall = calls.find( + (call) => call.name === 'runtime_tool_canvas_asset_generate', + ); + if (canvasCall) { + assert( + !canvasCallsByAgent.has(agentId), + `self-test-canvas-generation-duplicate:${agentId}`, + ); + canvasCallsByAgent.set(agentId, canvasCall); + } + if (calls.some((call) => call.name === 'respond_to_user')) { terminalCompletionCount += 1; break; } @@ -549,10 +619,429 @@ async function runSelfTest() { `self-test-manifest-ready-terminal-invalid:${agentId}`, ); } + const artDirectorAssetCall = canvasCallsByAgent.get('art-director'); + const designFoundationAssetCall = canvasCallsByAgent.get('design-foundation'); + const artAssetPlanAssetCall = canvasCallsByAgent.get('art-asset-plan'); + assert( + JSON.stringify(callBatchesByAgent.get('art-asset-plan')) === + JSON.stringify([ + ['runtime_tool_asset_list'], + ['runtime_tool_file_write'], + ['runtime_tool_canvas_asset_generate'], + ['runtime_tool_file_read', 'runtime_tool_asset_list'], + ['update_agent_plan', 'respond_to_user'], + ]), + 'self-test-art-asset-plan-revision-stages-invalid', + ); + assert( + JSON.stringify(callBatchesByAgent.get('code-prototype')) === + JSON.stringify([ + [ + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_file_read', + ], + [ + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_asset_list', + ], + ['runtime_tool_file_write'], + ['runtime_tool_command_run_limited'], + ['update_agent_plan', 'respond_to_user'], + ]), + 'self-test-code-prototype-asset-audit-sequence-invalid', + ); + assert( + canvasCallsByAgent.size === 3 && + artDirectorAssetCall?.arguments?.input?.outputPath === + 'assets/art-spec.png' && + artDirectorAssetCall.arguments.input.aspectRatio === '1:1' && + artDirectorAssetCall.arguments.input.assetKind === 'icon-spec' && + designFoundationAssetCall?.arguments?.input?.outputPath === + 'assets/ui-prototype.png' && + designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && + designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' && + artAssetPlanAssetCall?.arguments?.input?.outputPath === + 'assets/art-spritesheet.png' && + artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && + artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet', + 'self-test-visual-assets-invalid', + ); + + const visualRecoveryRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const visualRecoveryRunId = 'visual-recovery-design-foundation'; + const visualRecoveryTools = [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + 'runtime_tool_image_inspect', + ]; + const visualRecoveryRoute = ( + tools = visualRecoveryTools, + extraContext = '', + ) => + visualRecoveryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'design-foundation', + visualRecoveryRunId, + tools, + `处理 manifest ready 任务:design-foundation\n任务 ID:design-foundation${extraContext}`, + ), + }); + visualRecoveryRoute(); + visualRecoveryRoute(); + const visualRecoveryCanvasResponse = visualRecoveryRoute(); + const visualRecoveryInitialInspectResponse = visualRecoveryRoute(); + assert( + responseFunctionNames(visualRecoveryCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(visualRecoveryInitialInspectResponse).includes( + 'runtime_tool_image_inspect', + ), + 'self-test-ui-prototype-initial-inspection-sequence-invalid', + ); + const failedInspectObservation = { + tool: 'image.inspect', + status: 'failed', + summary: '视觉模型调用失败', + detail: 'upstream-422', + }; + const prematureCompletionResponse = visualRecoveryRoute( + ['update_agent_plan', 'respond_to_user'], + `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`, + ); + assert( + responseFunctionNames(prematureCompletionResponse).includes( + 'respond_to_user', + ), + 'self-test-ui-prototype-premature-completion-fixture-invalid', + ); + const visualBlockedObservation = { + tool: 'runtime.visual_asset', + status: 'blocked', + summary: 'UI 原型视觉检查尚未通过', + detail: + 'expectedPath=assets/ui-prototype.png · requiredInspection=image.inspect', + }; + const blockedRetryResponse = visualRecoveryRoute( + visualRecoveryTools, + `\n已有工具观察:\n${JSON.stringify([visualBlockedObservation])}`, + ); + assert( + responseFunctionNames(blockedRetryResponse).join(',') === + 'runtime_tool_image_inspect', + 'self-test-ui-prototype-visual-block-did-not-retry-inspection', + ); + const failedRetryResponse = visualRecoveryRoute( + visualRecoveryTools, + `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`, + ); + assert( + responseFunctionNames(failedRetryResponse).join(',') === + 'runtime_tool_image_inspect', + 'self-test-ui-prototype-failed-inspection-did-not-retry', + ); + const successfulInspectObservation = { + tool: 'image.inspect', + status: 'ok', + summary: '视觉检查已完成,共分析 1 张图片', + detail: null, + }; + const visualRecoveryFinalResponse = visualRecoveryRoute( + ['update_agent_plan', 'respond_to_user'], + `\n已有工具观察:\n${JSON.stringify([successfulInspectObservation])}`, + ); + const visualRecoveryStats = visualRecoveryRouter.getStats(); + assert( + responseFunctionNames(visualRecoveryFinalResponse).includes( + 'respond_to_user', + ) && + visualRecoveryStats.manifestReadyTaskRunCount === 1 && + visualRecoveryStats.manifestReadyTaskCompletionCount === 1 && + visualRecoveryStats.manifestReadyTaskCanvasGenerationCount === 1 && + visualRecoveryStats.byAgent?.['design-foundation']?.projectMutation === + 3 && + visualRecoveryStats.unexpectedRequestCount === 0, + 'self-test-ui-prototype-visual-recovery-not-exactly-once', + ); + + const finalStaticResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_command_run_limited'], + `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`, + ); + assert( + responseFunctionNames(finalStaticResponse).join(',') === + 'runtime_tool_command_run_limited', + 'self-test-final-static-not-exclusive', + ); + const successfulFinalStaticObservation = { + tool: 'command.run_limited', + status: 'ok', + summary: 'game.static_smoke 已完成', + detail: null, + }; + const finalPreviewResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_preview_validate'], + observationContext([successfulFinalStaticObservation]), + ); + assert( + responseFunctionNames(finalPreviewResponse).join(',') === + 'runtime_tool_preview_validate', + 'self-test-final-preview-not-exclusive', + ); + const successfulFinalPreviewObservation = { + tool: 'preview.validate', + status: 'ok', + summary: '桌面与移动视口真实试玩已通过', + detail: null, + }; + const finalHistoryResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_agent_action_history'], + observationContext([successfulFinalPreviewObservation]), + ); + const finalHistoryCalls = responseFunctionCalls(finalHistoryResponse); + assert( + finalHistoryCalls.length === 1 && + finalHistoryCalls[0]?.name === 'runtime_tool_agent_action_history' && + JSON.stringify(finalHistoryCalls[0]?.arguments?.input) === + JSON.stringify({ + runId: null, + actionId: null, + tool: null, + status: 'ok', + limit: 10, + }), + 'self-test-final-action-history-invalid', + ); + const staticActionId = `action-${'1'.repeat(24)}`; + const previewActionId = `action-${'2'.repeat(24)}`; + const actionHistoryDetail = JSON.stringify({ + runId: rootRunId, + count: 2, + truncated: false, + outputTruncated: false, + actions: [ + { + agentId: 'project-supervisor', + runId: rootRunId, + actionId: staticActionId, + tool: 'command.run_limited', + status: 'ok', + }, + { + agentId: 'project-supervisor', + runId: rootRunId, + actionId: previewActionId, + tool: 'preview.validate', + status: 'ok', + }, + ], + }); + const finalAcceptanceResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_agent_acceptance_update'], + observationContext([ + { + tool: 'agent.action_history', + status: 'ok', + summary: '已读取当前 Agent 的 2 条终态动作', + detail: actionHistoryDetail, + }, + ]), + ); + const finalAcceptanceCalls = responseFunctionCalls(finalAcceptanceResponse); + const acceptanceInput = finalAcceptanceCalls[0]?.arguments?.input; + assert( + finalAcceptanceCalls.length === 1 && + finalAcceptanceCalls[0]?.name === + 'runtime_tool_agent_acceptance_update' && + acceptanceInput?.contractFingerprint === contractFingerprint && + acceptanceInput?.evaluations?.length === 2 && + acceptanceInput.evaluations[0]?.criterionId === + 'static-current-revision' && + acceptanceInput.evaluations[0]?.evidence?.[0]?.actionId === + staticActionId && + acceptanceInput.evaluations[1]?.criterionId === + 'playable-current-revision' && + acceptanceInput.evaluations[1]?.evidence?.[0]?.actionId === + previewActionId, + 'self-test-final-acceptance-update-invalid', + ); + const finalRespondResponse = route( + 'project-supervisor', + rootRunId, + ['respond_to_user'], + observationContext([ + { + tool: 'agent.acceptance_update', + status: 'ok', + summary: 'Acceptance Graph 已更新', + detail: null, + }, + ]), + ); + assert( + responseFunctionNames(finalRespondResponse).join(',') === 'respond_to_user', + 'self-test-final-respond-not-exclusive', + ); + + const planRejectionRouter = createDeterministicLaneDefenseRouter({ apiKey }); + planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + goalContractTools, + ), + }); + const planRejectionResponse = planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + ['respond_to_user', 'runtime_tool_file_patch', 'runtime_tool_file_write'], + '当前父 run 已进入只编排模式;本轮只允许收束或安全编排。', + ), + }); + const planRejectionWaitResponse = planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + ['runtime_tool_task_list', 'runtime_tool_agent_run_status'], + '当前正式 manifest DAG 仍有专业 task 在运行', + ), + }); + const planRejectionStats = planRejectionRouter.getStats(); + assert( + responseFunctionNames(planRejectionResponse).join(',') === + 'respond_to_user' && + responseFunctionNames(planRejectionWaitResponse).join(',') === + 'runtime_tool_task_list,runtime_tool_agent_run_status' && + planRejectionStats.byAgent?.['project-supervisor']?.projectMutation === + 0 && + planRejectionStats.delegateActionCount === 0 && + planRejectionStats.runStatusCount === 1 && + planRejectionStats.unexpectedRequestCount === 0, + 'self-test-supervisor-plan-rejection-did-not-converge-read-only', + ); + + const earlyFinalReplyRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + assert( + captureProviderErrorCode(() => + earlyFinalReplyRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'early-final-reply-run', + [], + '给用户一个正常中文回复', + ), + }), + ) === 'provider-parent-final-reply-before-acceptance', + 'self-test-parent-final-reply-bypassed-acceptance', + ); + + const acceptanceGuardRunId = 'acceptance-guard-run'; + const acceptanceGuardRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const acceptanceGuardRoute = (tools, extraContext = '') => + acceptanceGuardRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + acceptanceGuardRunId, + tools, + extraContext, + ), + }); + acceptanceGuardRoute(goalContractTools); + acceptanceGuardRoute( + ['runtime_tool_command_run_limited'], + `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`, + ); + acceptanceGuardRoute( + ['runtime_tool_preview_validate'], + observationContext([successfulFinalStaticObservation]), + ); + acceptanceGuardRoute( + ['runtime_tool_agent_action_history'], + observationContext([successfulFinalPreviewObservation]), + ); + const acceptanceGuardDetail = JSON.parse(actionHistoryDetail); + acceptanceGuardDetail.runId = acceptanceGuardRunId; + for (const action of acceptanceGuardDetail.actions) { + action.runId = acceptanceGuardRunId; + } + const actionHistoryObservationFor = (detail) => ({ + tool: 'agent.action_history', + status: 'ok', + summary: '已读取当前 Agent 的 2 条终态动作', + detail: JSON.stringify(detail), + }); + const ambiguousContractContext = `[Root Goal Contract]\n{"contractFingerprint":"${contractFingerprint}","nested":{"contractFingerprint":"${'b'.repeat(64)}"}}`; + assert( + captureProviderErrorCode(() => + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${ambiguousContractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(acceptanceGuardDetail), + ])}`, + ), + ) === 'provider-goal-contract-fingerprint-missing', + 'self-test-ambiguous-goal-contract-fingerprint-accepted', + ); + const invalidActionHistoryDetail = structuredClone(acceptanceGuardDetail); + invalidActionHistoryDetail.actions[0].actionId = 'action-invalid'; + assert( + captureProviderErrorCode(() => + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${contractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(invalidActionHistoryDetail), + ])}`, + ), + ) === 'provider-final-acceptance-evidence-missing', + 'self-test-invalid-action-history-id-accepted', + ); + assert( + responseFunctionNames( + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${contractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(acceptanceGuardDetail), + ])}`, + ), + ).join(',') === 'runtime_tool_agent_acceptance_update', + 'self-test-acceptance-stage-advanced-after-rejected-history', + ); + const stats = router.getStats(); const manifestReadyTasks = manifestReadyTaskEvidence(stats); assert( - manifestReadyTasks.exactlyOnce, + manifestReadyTasks.exactlyOnce && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.interactionExecuteCount === 1 && + stats.delegateActionCount === 0 && + stats.imageInspectionRequestCount === 1 && + stats.manifestReadyTaskCanvasGenerationCount === 3 && + stats.byAgent?.['project-supervisor']?.projectMutation === 0 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && + stats.byAgent?.['quality-review']?.projectMutation === 0, 'self-test-manifest-ready-exactly-once-invalid', ); @@ -597,6 +1086,20 @@ async function runSelfTest() { detail: 'repositoryContextDrift=true · 请在同一 run 下一轮 planning 重新确认适用规范', }; + const projectLockSmokeObservations = [ + { + tool: 'command.run_limited', + status: 'failed', + summary: 'game.static_smoke 无法取得项目验证锁', + detail: '项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock', + }, + { + tool: 'command.run_limited', + status: 'failed', + summary: 'game.static_smoke 无法取得项目验证锁', + detail: '项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock', + }, + ]; const projectRevisionDriftSmokeObservation = { tool: 'command.run_limited', status: 'blocked', @@ -604,43 +1107,179 @@ async function runSelfTest() { detail: 'projectRevisionDrift=true · expectedRevision=4 · currentRevision=5 · replanRequired=true', }; + const projectRevisionDriftCanvasObservation = { + tool: 'canvas.asset_generate', + status: 'blocked', + summary: '并行项目变更使旧动作过期,旧动作未执行', + detail: + 'projectRevisionDrift=true · expectedRevision=5 · currentRevision=6 · replanRequired=true', + }; const successfulProjectMutationObservation = { tool: 'project.patchset', status: 'ok', summary: 'project.patchset 已原子应用 2 项变更', detail: 'checkpointId=checkpoint-concurrent-ready-batch', }; - const captureProviderErrorCode = (operation) => { - try { - operation(); - return null; - } catch (error) { - return error?.code ?? null; - } + const missingArtAssetObservation = { + tool: 'runtime.visual_asset', + status: 'blocked', + summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', + detail: '[redacted sensitive context]', + }; + const successfulCanvasMutationObservation = { + tool: 'canvas.asset_generate', + status: 'ok', + summary: '素材画布图片已生成并登记', + detail: null, }; - const oldSmokeRouter = createDeterministicLaneDefenseRouter({ apiKey }); - const oldSmokeRoute = (extraContext = '') => - oldSmokeRouter.route({ + const artAssetRecoveryAgentId = 'art-asset-plan'; + const artAssetRecoveryRunId = 'art-asset-visual-recovery-ready-run'; + const artAssetRecoveryTools = [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + ]; + const artAssetRecoveryRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const artAssetRecoveryRoute = (extraContext = '') => + artAssetRecoveryRouter.route({ authorization: `Bearer ${apiKey}`, payload: syntheticPayload( - 'code-prototype', - 'old-smoke-initial-code-run', - allTools, - extraContext, + artAssetRecoveryAgentId, + artAssetRecoveryRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, ), }); - oldSmokeRoute(); - oldSmokeRoute(); - oldSmokeRoute(); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(artAssetRecoveryRoute()).includes('respond_to_user') + ) { + break; + } + } + const artAssetRegenerationResponse = artAssetRecoveryRoute( + retryObservationContext([missingArtAssetObservation]), + ); + const artAssetReverificationResponse = artAssetRecoveryRoute( + retryObservationContext([successfulCanvasMutationObservation]), + ); + const artAssetRefinalizationResponse = artAssetRecoveryRoute( + retryObservationContext([successfulSmokeObservation]), + ); + const artAssetRecoveryStats = artAssetRecoveryRouter.getStats(); assert( - responseFunctionNames( - oldSmokeRoute(retryObservationContext([successfulSmokeObservation])), - ).includes('respond_to_user') && - captureProviderErrorCode(() => - oldSmokeRoute(retryObservationContext([successfulSmokeObservation])), - ) === 'provider-terminal-replay-unarmed:code-prototype', - 'self-test-initial-terminal-old-smoke-replayed', + responseFunctionNames(artAssetRegenerationResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetReverificationResponse).join(',') === + 'runtime_tool_command_run_limited' && + responseFunctionNames(artAssetRefinalizationResponse).includes( + 'respond_to_user', + ) && + artAssetRecoveryStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.completion === 1 && + artAssetRecoveryStats.manifestReadyTaskCanvasGenerationCount === 2 && + artAssetRecoveryStats.unexpectedRequestCount === 0, + 'self-test-art-asset-visual-recovery-not-exactly-once', + ); + + const artAssetRevisionDriftRunId = 'art-asset-revision-drift-ready-run'; + const artAssetRevisionDriftRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const artAssetRevisionDriftRoute = (extraContext = '') => + artAssetRevisionDriftRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + artAssetRecoveryAgentId, + artAssetRevisionDriftRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, + ), + }); + const artAssetInitialListResponse = artAssetRevisionDriftRoute(); + const artAssetManifestWriteResponse = artAssetRevisionDriftRoute(); + const artAssetInitialCanvasResponse = artAssetRevisionDriftRoute(); + const artAssetRetryCanvasResponse = artAssetRevisionDriftRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ); + const artAssetPostRetryReadResponse = artAssetRevisionDriftRoute( + retryObservationContext([successfulCanvasMutationObservation]), + ); + const artAssetRevisionDriftFinalizationResponse = + artAssetRevisionDriftRoute(); + const artAssetRevisionDriftStats = artAssetRevisionDriftRouter.getStats(); + assert( + responseFunctionNames(artAssetInitialListResponse).join(',') === + 'runtime_tool_asset_list' && + responseFunctionNames(artAssetManifestWriteResponse).join(',') === + 'runtime_tool_file_write' && + responseFunctionNames(artAssetInitialCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetRetryCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetPostRetryReadResponse).join(',') === + 'runtime_tool_file_read,runtime_tool_asset_list' && + responseFunctionNames(artAssetRevisionDriftFinalizationResponse).includes( + 'respond_to_user', + ) && + artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.run === 1 && + artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.completion === 1 && + artAssetRevisionDriftStats.manifestReadyTaskCanvasGenerationCount === 2 && + artAssetRevisionDriftStats.unexpectedRequestCount === 0, + 'self-test-art-asset-revision-drift-recovery-not-exactly-once', + ); + + const artAssetRevisionDriftLimitRunId = + 'art-asset-revision-drift-limit-ready-run'; + const artAssetRevisionDriftLimitRouter = createDeterministicLaneDefenseRouter( + { apiKey }, + ); + const artAssetRevisionDriftLimitRoute = (extraContext = '') => + artAssetRevisionDriftLimitRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + artAssetRecoveryAgentId, + artAssetRevisionDriftLimitRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, + ), + }); + artAssetRevisionDriftLimitRoute(); + artAssetRevisionDriftLimitRoute(); + artAssetRevisionDriftLimitRoute(); + for (let retry = 0; retry < 16; retry += 1) { + assert( + responseFunctionNames( + artAssetRevisionDriftLimitRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ), + ).join(',') === 'runtime_tool_canvas_asset_generate', + `self-test-art-asset-revision-drift-retry-invalid:${retry}`, + ); + } + const artAssetRevisionDriftLimitCode = captureProviderErrorCode(() => + artAssetRevisionDriftLimitRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ), + ); + const artAssetRevisionDriftLimitStats = + artAssetRevisionDriftLimitRouter.getStats(); + assert( + artAssetRevisionDriftLimitCode === + 'provider-ready-art-canvas-retry-exhausted' && + artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[ + artAssetRecoveryAgentId + ]?.run === 1 && + artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[ + artAssetRecoveryAgentId + ]?.completion === 0 && + artAssetRevisionDriftLimitStats.manifestReadyTaskCanvasGenerationCount === + 17, + 'self-test-art-asset-revision-drift-limit-invalid', ); const readOnlyCommandRouter = createDeterministicLaneDefenseRouter({ @@ -713,58 +1352,30 @@ async function runSelfTest() { 'self-test-ready-precompletion-limit-invalid', ); - const exerciseInitialTerminalRetryLimit = ({ - agentId, - runId, - tools, - initialRequestCount, - }) => { - const terminalRouter = createDeterministicLaneDefenseRouter({ apiKey }); - const terminalRoute = (extraContext = '') => - terminalRouter.route({ - authorization: `Bearer ${apiKey}`, - payload: syntheticPayload(agentId, runId, tools, extraContext), - }); - for (let request = 0; request < initialRequestCount; request += 1) { - terminalRoute(); - } - let deliveryCount = 0; - for (let retry = 0; retry < 16; retry += 1) { - assert( - responseFunctionNames( - terminalRoute(retryObservationContext([staleObservation])), - ).join(',') === 'runtime_tool_command_run_limited', - `self-test-initial-terminal-verification-invalid:${agentId}:${retry}`, - ); - if ( - responseFunctionNames( - terminalRoute(retryObservationContext([successfulSmokeObservation])), - ).includes('respond_to_user') - ) { - deliveryCount += 1; - } - } - const limitCode = captureProviderErrorCode(() => - terminalRoute(retryObservationContext([staleObservation])), - ); + const projectLockRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const projectLockRunId = 'project-lock-transient-ready-run'; + const projectLockRoute = (tools, extraContext = '') => + projectLockRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + preCompletionAgentId, + projectLockRunId, + tools, + `处理 manifest ready 任务:${preCompletionAgentId}\n任务 ID:${preCompletionAgentId}${extraContext}`, + ), + }); + projectLockRoute(manifestReadyTools); + for (const observation of projectLockSmokeObservations) { assert( - deliveryCount === 16 && - limitCode === `provider-terminal-retry-exhausted:${agentId}`, - `self-test-initial-terminal-retry-limit-invalid:${agentId}`, + responseFunctionNames( + projectLockRoute( + ['runtime_tool_command_run_limited'], + retryObservationContext([observation]), + ), + ).join(',') === 'runtime_tool_command_run_limited', + 'self-test-ready-project-lock-path-separator-not-retried', ); - }; - exerciseInitialTerminalRetryLimit({ - agentId: 'code-prototype', - runId: 'initial-code-terminal-retry-run', - tools: allTools, - initialRequestCount: 3, - }); - exerciseInitialTerminalRetryLimit({ - agentId: 'art-asset-plan', - runId: 'initial-art-terminal-retry-run', - tools: [...manifestReadyTools, 'runtime_tool_canvas_asset_generate'], - initialRequestCount: 5, - }); + } const duplicateRouter = createDeterministicLaneDefenseRouter({ apiKey }); const duplicateRoute = (runId, extraContext = '') => @@ -857,6 +1468,49 @@ async function runSelfTest() { 'self-test-ready-retry-limit-or-exactly-once-invalid', ); + const verifiedDeliveryRepairRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const verifiedDeliveryRepairRunId = 'verified-delivery-repair-ready-run'; + const verifiedDeliveryRepairRoute = (tools, extraContext = '') => + verifiedDeliveryRepairRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + retryAgentId, + verifiedDeliveryRepairRunId, + tools, + `处理 manifest ready 任务:${retryAgentId}\n任务 ID:${retryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames( + verifiedDeliveryRepairRoute(manifestReadyTools), + ).includes('respond_to_user') + ) { + break; + } + } + const rejectedVerificationPlan = verifiedDeliveryRepairRoute( + manifestReadyTools, + retryObservationContext([staleObservation]), + ); + const verifiedDeliveryRepairResponse = verifiedDeliveryRepairRoute( + ['respond_to_user'], + `${retryObservationContext([staleObservation])}\n上一条输出不符合工具计划协议:当前 revision 已通过验证。本次修复的原生工具目录只保留 respond_to_user;必须立即调用它交付专业合同结论。`, + ); + const verifiedDeliveryRepairStats = verifiedDeliveryRepairRouter.getStats(); + assert( + responseFunctionNames(rejectedVerificationPlan).join(',') === + 'runtime_tool_command_run_limited' && + responseFunctionNames(verifiedDeliveryRepairResponse).join(',') === + 'respond_to_user' && + verifiedDeliveryRepairStats.readyTaskCountsByAgent[retryAgentId] + ?.completion === 1 && + verifiedDeliveryRepairStats.unexpectedRequestCount === 0, + 'self-test-ready-verified-delivery-repair-invalid', + ); + const transientRetryRouter = createDeterministicLaneDefenseRouter({ apiKey }); const transientRetryRunId = 'transient-retry-ready-run'; const transientRetryRoute = (extraContext = '') => @@ -1094,23 +1748,24 @@ async function runSelfTest() { readOnlyRetryRouter.getStats().manifestReadyTaskStaticSmokeCount === 0, 'self-test-read-only-retry-used-forbidden-verification', ); - const responsibilityContract = responsibilityContractEvidence(stats); assert( - responsibilityContract.codeOwnsGameIndex && - responsibilityContract.qualityIsReadOnly && - responsibilityContract.qualityIndependentOfCodeTiming && - responsibilityContract.artOwnsGeneratedAsset && - responsibilityContract.contractViolationFree, - 'self-test-responsibility-contract-invalid', - ); - assert( - stats.initialDelegationCount === 3 && - stats.followupDelegationCount === 1 && - stats.supervisorDirectMutationAttemptCount === 1 && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.delegateActionCount === 0 && + stats.manifestReadyTaskCanvasGenerationCount === 3 && + stats.byAgent?.['project-supervisor']?.projectMutation === 0 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && + stats.byAgent?.['quality-review']?.projectMutation === 0 && stats.unexpectedRequestCount === 0, 'self-test-provider-core-stats-invalid', ); + const syntheticRuntimeProviderRequestCount = + stats.requestCount - + stats.interactionExecuteCount - + stats.imageInspectionRequestCount; + const syntheticChildReport = { status: 'PASS', suite, @@ -1138,10 +1793,10 @@ async function runSelfTest() { projectRevisionDelta: 2, finalSupervisorAssistantCount: 1, professionalAssistantCount: 3, - providerRequestIdentityCount: stats.requestCount, - providerLifecycleStartedCount: stats.requestCount, - providerLifecycleTerminalCount: stats.requestCount, - providerLifecycleCompletedCount: stats.requestCount, + providerRequestIdentityCount: syntheticRuntimeProviderRequestCount, + providerLifecycleStartedCount: syntheticRuntimeProviderRequestCount, + providerLifecycleTerminalCount: syntheticRuntimeProviderRequestCount, + providerLifecycleCompletedCount: syntheticRuntimeProviderRequestCount, }, }; const incompletePlaytestReport = structuredClone(syntheticChildReport); @@ -1195,14 +1850,39 @@ async function runSelfTest() { run: duplicateRunCode, }, terminalRetryContractsValidated: { - oldSmokeObservationSingleUse: true, readOnlyCommandOnlyRejected: true, preCompletionTransientRetryLimit: 16, - initialCodeRetryLimit: 16, - initialArtRetryLimit: 16, + projectLockPathSeparatorsRetried: ['/', '\\'], }, - responsibilityContract, - qualityTimingOrdersValidated: ['before-code', 'after-code'], + providerRuntimeModeValidated: true, + interactionExecutionValidated: { + reasoningEffort: 'max', + requestCount: stats.interactionExecuteCount, + responseTools: interactionExecuteCalls.map((call) => call.name), + readyTaskRunCountBeforeRuntime: + interactionExecuteStats.manifestReadyTaskRunCount, + readyTaskCompletionCountBeforeRuntime: + interactionExecuteStats.manifestReadyTaskCompletionCount, + }, + goalContractValidated: true, + finalizationSequenceValidated: [ + 'command.run_limited', + 'preview.validate', + 'agent.action_history', + 'agent.acceptance_update', + 'respond_to_user', + ], + canvasArtifactsValidated: [ + 'assets/art-spec.png', + 'assets/ui-prototype.png', + 'assets/art-spritesheet.png', + ], + uiPrototypeInspectionValidated: true, + uiPrototypeVisualRecoveryExactlyOnceValidated: true, + artAssetVisualRecoveryExactlyOnceValidated: true, + artAssetRevisionDriftRecoveryExactlyOnceValidated: true, + artAssetRevisionDriftRetryLimitValidated: 16, + supervisorPlanRejectionMode: 'respond-only-then-read-only-wait', childHardGatesValidated: true, packageCommandsRegistered: true, }; @@ -1228,23 +1908,7 @@ async function runE2e(options) { { flag: 'wx', mode: 0o600 }, ); provider = await startDeterministicLaneDefenseProvider({ apiKey }); - const config = { - editorApi: { - apiKey, - baseUrl: provider.editorBaseUrl, - }, - llm: { - apiKey, - baseUrl: provider.baseUrl, - model: deterministicLaneDefenseModel, - apiKind: 'openai_chat', - reasoningEffort: 'low', - stream: false, - requestTimeoutMs: 30_000, - maxRetries: 0, - retryBackoffMs: 100, - }, - }; + const config = deterministicRuntimeConfig(apiKey, provider); await fs.writeFile( path.join(configDir, configFileName), `${JSON.stringify(config)}\n`, @@ -1298,7 +1962,6 @@ async function runE2e(options) { const providerPassed = providerStats?.stopped === true && expectedProviderStats(providerStats); const manifestReadyTasks = manifestReadyTaskEvidence(providerStats); - const responsibilityContract = responsibilityContractEvidence(providerStats); const status = !failureCode && childPassed && providerPassed && configRemoved ? 'PASS' @@ -1318,7 +1981,14 @@ async function runE2e(options) { child: childReport, provider: providerStats, manifestReadyTasks, - responsibilityContract, + finalizationContract: { + goalContractCount: providerStats?.goalContractCount ?? null, + actionHistoryCount: providerStats?.actionHistoryCount ?? null, + acceptanceUpdateCount: providerStats?.acceptanceUpdateCount ?? null, + delegateActionCount: providerStats?.delegateActionCount ?? null, + supervisorProjectMutationCount: + providerStats?.byAgent?.['project-supervisor']?.projectMutation ?? null, + }, cleanup: { providerStopped: providerStats?.stopped === true, configRemoved, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs index fb509b580..72448f213 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs @@ -2406,11 +2406,36 @@ export function isolatedJoinDeliveryTarget(delivery) { return target; } -export function finalMessageId(agentId, sessionId, runId) { - const fingerprint = createHash('sha256') +function runtimeMessageCorrelationId(agentId, sessionId, runId) { + return createHash('sha256') .update(`${agentId}\n${sessionId}\n${runId}`) .digest('hex'); - return `agent-finalization-${fingerprint.slice(0, 32)}`; +} + +export function finalMessageId(agentId, sessionId, runId) { + return `agent-finalization-${runtimeMessageCorrelationId( + agentId, + sessionId, + runId, + ).slice(0, 32)}`; +} + +export function runtimePublicStatusMessageId( + agentId, + sessionId, + runId, + status, +) { + const correlationId = runtimeMessageCorrelationId( + agentId, + sessionId, + runId, + ).slice(0, 32); + const statusFingerprint = createHash('sha256') + .update(status) + .digest('hex') + .slice(0, 16); + return `runtime-public-status-${correlationId}-${statusFingerprint}`; } export function backgroundTaskMessageId(agentId, sessionId, runId, source) { @@ -2444,7 +2469,10 @@ export function disposableProjectPathVariants() { } export function formalConfigPathVariants() { - return absolutePathVariants(state.options?.configDir); + return absolutePathVariants( + state.options?.configDir, + state.isolatedRunner?.appDataDir, + ); } export function absolutePathVariants(...values) { diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs index ca6b520fb..93d668d4d 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs @@ -20,7 +20,15 @@ import { stopOwnedIsolatedRunner, } from './harness/app-data.mjs'; import { loadConfig, parseArguments } from './harness/config.mjs'; -import { closeInteractiveCli } from './harness/process.mjs'; +import { + activeInteractiveCliSessions, + captureOwnedProcessCleanupSnapshot, + closeInteractiveCli, + destroyInteractiveCliOutputStreams, + interactiveCliOutput, + verifyOwnedProcessCleanupSnapshot, + waitForInteractiveCliStdioClose, +} from './harness/process.mjs'; import { checkPrerequisites } from './harness/project.mjs'; import { buildSummary, @@ -266,6 +274,21 @@ if (selfTestRequested) { recordError(error?.code ?? 'unexpected-error', error); } finally { state.cleanupInProgress = true; + const stateTrackedInteractiveCliSessions = new Set( + [ + state.userInputCliSession, + state.supervisorAutonomousPlayableCliSession, + state.supervisorSwarmCliSession, + ].filter(Boolean), + ); + const interactiveCliSessions = [ + ...new Set([ + ...stateTrackedInteractiveCliSessions, + ...activeInteractiveCliSessions, + ]), + ]; + const supervisorAutonomousPlayableCliSession = + state.supervisorAutonomousPlayableCliSession; if (isUserInputRuntimeSuite() && state.userInputCliSession) { try { await closeInteractiveCli(state.userInputCliSession); @@ -311,6 +334,15 @@ if (selfTestRequested) { } state.supervisorSwarmCliSession = null; } + for (const session of interactiveCliSessions) { + if (stateTrackedInteractiveCliSessions.has(session)) continue; + try { + await closeInteractiveCli(session); + } catch (error) { + state.status = 'FAIL'; + recordError('interactive-cli-cleanup-failed', error); + } + } if (isMcpRuntimeSuite() && state.mcp.httpFixture) { try { await stopMcpHttpFixture(); @@ -320,16 +352,42 @@ if (selfTestRequested) { recordError('mcp-http-fixture-cleanup-failed', error); } } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + state.isolatedRunner.appDataDir + ) { + try { + const runnerPid = state.isolatedRunner.current?.pid ?? null; + const helperPid = + state.isolatedRunner.current?.killHandle?.child?.pid ?? null; + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot = + await captureOwnedProcessCleanupSnapshot({ + runnerPid, + helperPids: Number.isSafeInteger(helperPid) ? [helperPid] : [], + rootPids: [ + ...interactiveCliSessions.map((session) => session.child?.pid), + ...[...activeCommandChildren].map((child) => child.pid), + ].filter((pid) => Number.isSafeInteger(pid) && pid > 0), + }); + const observed = + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot + .observedCounts; + assert( + observed.runner === 1 && observed.helper === 1, + 'supervisor-autonomous-playable-owned-process-snapshot-incomplete', + ); + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-snapshot-failed', + error, + ); + } + } if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) { try { await stopOwnedIsolatedRunner(); state.isolatedRunner.stopped = true; - state.isolatedRunner.cleanupPerformed = - await removeIsolatedSuiteAppData(); - if (!state.isolatedRunner.cleanupPerformed) { - state.status = 'FAIL'; - recordError('isolated-appdata-cleanup-sentinel-missing'); - } } catch (error) { state.status = 'FAIL'; const safeCleanupErrorCode = @@ -342,8 +400,51 @@ if (selfTestRequested) { state.isolatedRunner.current?.killHandle, ).catch(() => {}); } + } + for (const session of interactiveCliSessions) { + try { + await waitForInteractiveCliStdioClose(session, 10_000); + } catch (error) { + destroyInteractiveCliOutputStreams(session); + state.status = 'FAIL'; + recordError( + error?.code === 'interactive-cli-stdio-close-timeout' + ? error.code + : 'interactive-cli-stdio-cleanup-failed', + error, + ); + } + } + if (supervisorAutonomousPlayableCliSession) { + state.supervisorAutonomousPlayable.cliOutput = interactiveCliOutput( + supervisorAutonomousPlayableCliSession, + ); + } + if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) { + if (state.isolatedRunner.stopped) { + try { + state.isolatedRunner.cleanupPerformed = + await removeIsolatedSuiteAppData(); + if (!state.isolatedRunner.cleanupPerformed) { + state.status = 'FAIL'; + recordError('isolated-appdata-cleanup-sentinel-missing'); + } + } catch (error) { + state.status = 'FAIL'; + const safeCleanupErrorCode = + isNonEmptyString(error?.code) && + /^(?:isolated|source)-[a-z0-9-]+$/u.test(error.code) + ? error.code + : 'isolated-appdata-cleanup-failed'; + recordError(safeCleanupErrorCode, error); + } + } const killMethod = - state.isolatedRunner.pidfdClaimCount > 0 ? 'linux-pidfd' : null; + state.isolatedRunner.pidfdClaimCount > 0 + ? process.platform === 'win32' + ? 'windows-process-handle' + : 'linux-pidfd' + : null; if (isSteerRunnerKillSuite()) { state.evidence.steerRunnerStopped = state.isolatedRunner.stopped; state.evidence.steerAppDataCleanupPerformed = @@ -661,6 +762,61 @@ if (selfTestRequested) { ); } } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + state.isolatedRunner.appDataDir + ) { + const snapshot = + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot; + if (snapshot) { + try { + const cleanup = await verifyOwnedProcessCleanupSnapshot(snapshot); + state.evidence.ownedProcessIdentityCaptured = true; + state.evidence.ownedRunnerObservedCount = + snapshot.observedCounts.runner; + state.evidence.ownedHelperObservedCount = + snapshot.observedCounts.helper; + state.evidence.ownedNodeDescendantObservedCount = + snapshot.observedCounts.node; + state.evidence.ownedBrowserDescendantObservedCount = + snapshot.observedCounts.browser; + state.evidence.ownedCommandDescendantObservedCount = + snapshot.observedCounts.command; + state.evidence.ownedRunnerResidualCount = + cleanup.residualCounts.runner; + state.evidence.ownedHelperResidualCount = + cleanup.residualCounts.helper; + state.evidence.ownedNodeDescendantResidualCount = + cleanup.residualCounts.node; + state.evidence.ownedBrowserDescendantResidualCount = + cleanup.residualCounts.browser; + state.evidence.ownedCommandDescendantResidualCount = + cleanup.residualCounts.command; + state.evidence.activeCommandChildrenAfterCleanup = + cleanup.activeCommandChildCount; + state.evidence.activeInteractiveCliSessionsAfterCleanup = + cleanup.activeInteractiveCliSessionCount; + state.evidence.ownedProcessCleanupPassed = cleanup.clean; + if (!cleanup.clean) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-residual-detected', + ); + } + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-verification-failed', + error, + ); + } + } else { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-snapshot-missing', + ); + } + } if ( isSteerRunnerKillSuite() && state.projectRoot && @@ -1210,6 +1366,7 @@ if (selfTestRequested) { const safeSummary = { status: state.status, suite: state.suite, + providerUsed: false, blocked: state.blocked, cleanup: { performed: state.cleanupPerformed, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs index becaa44d4..251fe1321 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs @@ -1,5 +1,8 @@ import { assert, codedError, hashValue, sleep } from '../assertions/core.mjs'; -import { isNonEmptyString } from '../assertions/runtime.mjs'; +import { + absolutePathVariants, + isNonEmptyString, +} from '../assertions/runtime.mjs'; import { createHash, fs, @@ -10,6 +13,7 @@ import { watchFileSystem, } from '../dependencies.mjs'; import { + activeCommandChildren, appRoot, BlockedError, configFileName, @@ -57,6 +61,7 @@ import { userInputAppDataSentinelSchema, webSearchAppDataSentinelFileName, webSearchAppDataSentinelSchema, + windowsProcessHandleHelperSource, } from '../runtime-state.mjs'; import { isContextCompactionSuite } from '../suites/context-compaction.mjs'; import { isGoalRuntimeSuite } from '../suites/goal.mjs'; @@ -66,7 +71,10 @@ import { isProjectSkillSuite } from '../suites/project-skill.mjs'; import { isResponseStreamSuite } from '../suites/response-stream.mjs'; import { isScopedAgentsSuite } from '../suites/scoped-agents.mjs'; import { isSteerRunnerKillSuite } from '../suites/steer-runner-kill.mjs'; -import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs'; +import { + isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, +} from '../suites/supervisor-autonomous-playable.mjs'; import { isSupervisorSwarmAutonomousChatSuite, isSupervisorSwarmCollaborationPolicyMixedRecoverySuite, @@ -112,6 +120,27 @@ export function isolatedSuiteUsesSiblingAppData() { return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData(); } +export function sameSupervisorPlayableProviderBinding(left, right) { + const scalarFields = [ + 'providerAgentMode', + 'providerModel', + 'providerApiKind', + 'providerReasoningEffort', + 'providerBaseUrlSha256', + ]; + return ( + left != null && + right != null && + scalarFields.every((field) => left[field] === right[field]) && + Array.isArray(left.boundAgentIds) && + Array.isArray(right.boundAgentIds) && + left.boundAgentIds.length === right.boundAgentIds.length && + left.boundAgentIds.every( + (agentId, index) => agentId === right.boundAgentIds[index], + ) + ); +} + export function isolatedSuiteAppDataProfile() { if (isSteerRunnerKillSuite()) { return { @@ -278,12 +307,19 @@ export async function createSentinelOwnedTempDirectory({ }) { const directory = await fs.mkdtemp(prefix); try { - if (process.platform !== 'win32') await fs.chmod(directory, 0o700); - await fs.writeFile( - path.join(directory, sentinelName), - `${JSON.stringify(sentinel)}\n`, - { flag: 'wx', mode: 0o600 }, - ); + if (process.platform === 'win32') { + await secureWindowsOwnedTempPath(directory, true); + } else { + await fs.chmod(directory, 0o700); + } + const sentinelPath = path.join(directory, sentinelName); + await fs.writeFile(sentinelPath, `${JSON.stringify(sentinel)}\n`, { + flag: 'wx', + mode: 0o600, + }); + if (process.platform === 'win32') { + await secureWindowsOwnedTempPath(sentinelPath, false); + } return directory; } catch (error) { try { @@ -298,6 +334,91 @@ export async function createSentinelOwnedTempDirectory({ } } +export async function secureWindowsOwnedTempPath(targetPath, isDirectory) { + assert( + process.platform === 'win32' && path.isAbsolute(targetPath), + 'isolated-windows-owned-path-precondition-invalid', + ); + const powershell = await findControlledWindowsPowerShell(); + const script = String.raw` +$ErrorActionPreference = 'Stop' +$targetPath = [Environment]::GetEnvironmentVariable( + 'AGC_OWNED_TEMP_PATH', + [EnvironmentVariableTarget]::Process) +$isDirectory = [Environment]::GetEnvironmentVariable( + 'AGC_OWNED_TEMP_PATH_IS_DIRECTORY', + [EnvironmentVariableTarget]::Process) -eq '1' +if ([String]::IsNullOrWhiteSpace($targetPath)) { exit 70 } +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$inheritance = [System.Security.AccessControl.InheritanceFlags]::None +if ($isDirectory) { + if (-not (Test-Path -LiteralPath $targetPath -PathType Container)) { exit 71 } + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($sid) + $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow) +} else { + if (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { exit 72 } + $acl = [System.Security.AccessControl.FileSecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($sid) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow) +} +[void]$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $targetPath -AclObject $acl +$targetItem = Get-Item -LiteralPath $targetPath -Force +$verified = $targetItem.GetAccessControl() +$verifiedOwner = $verified.GetOwner( + [System.Security.Principal.SecurityIdentifier]) +$verifiedRules = @($verified.GetAccessRules( + $true, + $true, + [System.Security.Principal.SecurityIdentifier])) +if ( + -not $verifiedOwner.Equals($sid) -or + -not $verified.AreAccessRulesProtected -or + $verifiedRules.Count -ne 1 +) { exit 73 } +$verifiedRule = $verifiedRules[0] +if ( + -not $verifiedRule.IdentityReference.Equals($sid) -or + $verifiedRule.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow -or + [int]$verifiedRule.FileSystemRights -ne + [int][System.Security.AccessControl.FileSystemRights]::FullControl -or + $verifiedRule.InheritanceFlags -ne $inheritance -or + $verifiedRule.PropagationFlags -ne + [System.Security.AccessControl.PropagationFlags]::None -or + $verifiedRule.IsInherited +) { exit 74 } +`; + try { + await runProcess( + powershell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + cwd: appRoot, + timeoutMs: 30_000, + env: controlledWindowsPowerShellEnvironment({ + AGC_OWNED_TEMP_PATH: targetPath, + AGC_OWNED_TEMP_PATH_IS_DIRECTORY: isDirectory ? '1' : '0', + }), + }, + ); + } catch (error) { + throw codedError('isolated-windows-owned-path-secure-failed', error); + } +} + export async function captureSourceRunnerEndpointSnapshot(sourceConfigDir) { const endpointPath = path.join(sourceConfigDir, runnerEndpointFileName); const metadata = await fs.lstat(endpointPath).catch((error) => { @@ -439,8 +560,11 @@ export async function prepareIsolatedSuiteAppData({ state.isolatedRunner.appDataDir = appDataDir; state.isolatedRunner.ownerToken = ownerToken; state.isolatedRunner.createdAt = createdAt; + const realAppDataDir = await fs.realpath(appDataDir); + state.formalConfigPathTranscriptScanner?.addSecrets( + absolutePathVariants(appDataDir, realAppDataDir), + ); if (isolatedSuiteProtectsSourceAppData()) { - const realAppDataDir = await fs.realpath(appDataDir); assert( !isPathInside(sourceConfigDir, realAppDataDir) && path.dirname(realAppDataDir) === path.dirname(sourceConfigDir), @@ -708,6 +832,56 @@ export async function prepareIsolatedSuiteAppData({ }); assert(!unexpectedEndpoint, `${profile.codePrefix}-endpoint-preexisted`); state.runtimeConfigDir = appDataDir; + if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + const isolatedConfig = await loadConfig(appDataDir); + const expectedBinding = state.config.providerBinding; + const agentIds = isSupervisorGameChatSingleMainPlayableSuite() + ? [ + projectSupervisorAgentId, + mainAgentId, + 'art-director', + 'art-asset-plan', + ] + : [projectSupervisorAgentId]; + const bindings = agentIds.map((agentId) => { + const effective = effectiveAgentLlmConfig(isolatedConfig.config, agentId); + assert( + ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].every( + (key) => isNonEmptyString(effective[key]), + ), + 'supervisor-autonomous-playable-effective-provider-incomplete', + ); + return { + providerModel: effective.model.trim(), + providerApiKind: effective.apiKind.trim(), + providerReasoningEffort: effective.reasoningEffort.trim(), + providerBaseUrlSha256: hashValue(effective.baseUrl.trim()), + }; + }); + const effectiveBinding = { + ...bindings[0], + ...(isSupervisorGameChatSingleMainPlayableSuite() + ? { providerAgentMode: isolatedConfig.config.agentMode?.trim() ?? null } + : {}), + boundAgentIds: [...agentIds].sort(), + }; + assert( + expectedBinding && + bindings.every( + (binding) => JSON.stringify(binding) === JSON.stringify(bindings[0]), + ) && + sameSupervisorPlayableProviderBinding( + effectiveBinding, + expectedBinding, + ), + 'supervisor-autonomous-playable-effective-provider-binding-mismatch', + ); + state.supervisorAutonomousPlayable.expectedProviderBinding = { + ...expectedBinding, + }; + state.supervisorAutonomousPlayable.effectiveProviderBinding = + effectiveBinding; + } if (streamAgentId) { const isolatedConfig = await loadConfig(appDataDir); const isolatedEffective = effectiveAgentLlmConfig( @@ -1066,22 +1240,27 @@ export async function verifyOwnedRunnerForKill() { } export async function ensureOwnedRunnerStableKillSupport() { - assert( - process.platform === 'linux', - 'isolated-runner-stable-kill-handle-platform-unsupported', - ); - const python = await findControlledLinuxPython(); - const probe = - 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)'; - try { - await runProcess(python, ['-I', '-S', '-c', probe], { - cwd: appRoot, - timeoutMs: 30_000, - env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, - }); - } catch (error) { - throw codedError('isolated-runner-pidfd-support-unavailable', error); + if (process.platform === 'linux') { + const python = await findControlledLinuxPython(); + const probe = + 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)'; + try { + await runProcess(python, ['-I', '-S', '-c', probe], { + cwd: appRoot, + timeoutMs: 30_000, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + }); + } catch (error) { + throw codedError('isolated-runner-pidfd-support-unavailable', error); + } + return; } + if (process.platform === 'win32') { + const handle = await openOwnedRunnerKillHandle(process.pid); + await closeOwnedRunnerKillHandle(handle); + return; + } + throw codedError('isolated-runner-stable-kill-handle-platform-unsupported'); } export async function findControlledLinuxPython() { @@ -1099,28 +1278,140 @@ export async function findControlledLinuxPython() { throw codedError('isolated-runner-controlled-python-unavailable'); } +export async function findControlledWindowsPowerShell() { + if (state.windowsProcessHandlePowerShellPath) + return state.windowsProcessHandlePowerShellPath; + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + assert( + isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot), + 'isolated-runner-windows-system-root-invalid', + ); + const candidate = path.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + const resolved = await fs.realpath(candidate).catch(() => null); + const metadata = resolved ? await fs.stat(resolved).catch(() => null) : null; + assert( + metadata?.isFile(), + 'isolated-runner-controlled-powershell-unavailable', + ); + state.windowsProcessHandlePowerShellPath = resolved; + return resolved; +} + +export function controlledWindowsPowerShellEnvironment(extra = {}) { + const systemRoot = process.env.SystemRoot ?? process.env.windir; + assert( + isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot), + 'isolated-runner-windows-system-root-invalid', + ); + const powershellRoot = path.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + ); + const programFiles = process.env.ProgramW6432 ?? process.env.ProgramFiles; + const moduleRoots = [path.join(powershellRoot, 'Modules')]; + if (isNonEmptyString(programFiles) && path.isAbsolute(programFiles)) { + moduleRoots.push(path.join(programFiles, 'WindowsPowerShell', 'Modules')); + } + const environment = { + SystemRoot: systemRoot, + windir: systemRoot, + ComSpec: path.join(systemRoot, 'System32', 'cmd.exe'), + PATH: [powershellRoot, path.join(systemRoot, 'System32'), systemRoot].join( + path.delimiter, + ), + PATHEXT: '.COM;.EXE;.BAT;.CMD', + PSModulePath: moduleRoots.join(path.delimiter), + }; + for (const name of ['TEMP', 'TMP', 'LOCALAPPDATA', 'USERPROFILE']) { + if (isNonEmptyString(process.env[name])) + environment[name] = process.env[name]; + } + if (isNonEmptyString(programFiles)) environment.ProgramFiles = programFiles; + return { ...environment, ...extra }; +} + export async function openOwnedRunnerKillHandle(pid) { assert( - process.platform === 'linux' && Number.isSafeInteger(pid) && pid > 1, - 'isolated-runner-pidfd-open-precondition-invalid', - ); - const python = await findControlledLinuxPython(); - const child = spawn( - python, - ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)], - { - cwd: appRoot, - env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, - stdio: ['pipe', 'pipe', 'pipe'], - }, + Number.isSafeInteger(pid) && pid > 1, + 'isolated-runner-stable-kill-handle-open-precondition-invalid', ); + let child; + let readyMarker; + let exitedMarker; + let kind; + if (process.platform === 'linux') { + const python = await findControlledLinuxPython(); + child = spawn( + python, + ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)], + { + cwd: appRoot, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + readyMarker = Buffer.from('PIDFD_READY\n'); + exitedMarker = Buffer.from('PIDFD_EXITED\n'); + kind = 'linux-pidfd'; + } else if (process.platform === 'win32') { + const powershell = await findControlledWindowsPowerShell(); + child = spawn( + powershell, + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(windowsProcessHandleHelperSource, 'utf16le').toString( + 'base64', + ), + ], + { + cwd: appRoot, + env: controlledWindowsPowerShellEnvironment({ + AGC_OWNED_RUNNER_PID: String(pid), + }), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + readyMarker = Buffer.from('HANDLE_READY'); + exitedMarker = Buffer.from('HANDLE_EXITED'); + kind = 'windows-process-handle'; + } else { + throw codedError('isolated-runner-stable-kill-handle-platform-unsupported'); + } const handle = { + kind, + readyMarker, + exitedMarker, pid, child, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), closed: false, + command: null, + stdinError: null, }; + activeCommandChildren.add(child); + child.once('error', () => { + if (!Number.isSafeInteger(child.pid)) activeCommandChildren.delete(child); + }); + child.once('close', () => { + activeCommandChildren.delete(child); + handle.closed = true; + }); + child.stdin.on('error', (error) => { + handle.stdinError ??= error; + }); child.stdout.on('data', (chunk) => { handle.stdout = appendBounded(handle.stdout, chunk, 4_096); }); @@ -1134,8 +1425,15 @@ export async function openOwnedRunnerKillHandle(pid) { export async function waitForOwnedRunnerKillHandleReady(handle) { await new Promise((resolve, reject) => { const timer = setTimeout(() => { - handle.child.kill('SIGKILL'); - reject(codedError('isolated-runner-pidfd-open-timeout')); + settle(() => { + void terminateOwnedRunnerKillHandleHelper(handle).then( + () => + reject( + codedError('isolated-runner-stable-kill-handle-open-timeout'), + ), + reject, + ); + }); }, 10_000); const settle = (callback) => { clearTimeout(timer); @@ -1145,16 +1443,23 @@ export async function waitForOwnedRunnerKillHandleReady(handle) { callback(); }; const onData = () => { - if (handle.stdout.includes(Buffer.from('PIDFD_READY\n'))) { + if (handle.stdout.includes(handle.readyMarker)) { settle(resolve); } }; const onError = (error) => settle(() => - reject(codedError('isolated-runner-pidfd-helper-spawn-failed', error)), + reject( + codedError( + 'isolated-runner-stable-kill-handle-helper-spawn-failed', + error, + ), + ), ); const onClose = () => - settle(() => reject(codedError('isolated-runner-pidfd-open-failed'))); + settle(() => + reject(codedError('isolated-runner-stable-kill-handle-open-failed')), + ); handle.child.stdout.on('data', onData); handle.child.on('error', onError); handle.child.on('close', onClose); @@ -1164,55 +1469,250 @@ export async function waitForOwnedRunnerKillHandleReady(handle) { export async function closeOwnedRunnerKillHandle(handle) { if (!handle || handle.closed) return; - handle.closed = true; - if (handle.child.exitCode !== null || handle.child.signalCode !== null) + if (handle.child.exitCode !== null || handle.child.signalCode !== null) { + await waitForChildClose(handle.child, 2_000, 5_000); + handle.closed = true; return; - handle.child.stdin.end('CLOSE\n'); - const result = await waitForChildClose(handle.child, 10_000); - assert(result.code === 0, 'isolated-runner-pidfd-close-failed'); + } + if (handle.command !== null) { + await terminateOwnedRunnerKillHandleHelper(handle); + return; + } + handle.command = 'CLOSE'; + try { + await writeOwnedRunnerKillHandleCommand(handle, 'CLOSE'); + const result = await waitForChildClose(handle.child, 10_000); + assert( + result.code === 0, + 'isolated-runner-stable-kill-handle-close-failed', + ); + handle.closed = true; + } catch (error) { + await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error); + } } export async function signalOwnedRunnerKillHandle(handle) { assert( handle && handle.closed === false && + handle.command === null && handle.child.exitCode === null && handle.child.signalCode === null, - 'isolated-runner-pidfd-handle-not-live', - ); - handle.closed = true; - handle.child.stdin.end('KILL\n'); - const result = await waitForChildClose(handle.child, 15_000); - assert( - result.code === 0 && handle.stdout.includes(Buffer.from('PIDFD_EXITED\n')), - 'isolated-runner-pidfd-sigkill-failed', + 'isolated-runner-stable-kill-handle-not-live', ); + handle.command = 'KILL'; + try { + await writeOwnedRunnerKillHandleCommand(handle, 'KILL'); + const result = await waitForChildClose(handle.child, 15_000); + assert( + result.code === 0 && handle.stdout.includes(handle.exitedMarker), + 'isolated-runner-stable-kill-handle-signal-failed', + ); + handle.closed = true; + } catch (error) { + await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error); + } } -export async function waitForChildClose(child, timeoutMs) { +export async function writeOwnedRunnerKillHandleCommand( + handle, + command, + timeoutMs = 5_000, +) { + assert( + handle?.child?.stdin && + !handle.child.stdin.destroyed && + !handle.child.stdin.writableEnded && + ['CLOSE', 'KILL'].includes(command) && + Number.isSafeInteger(timeoutMs) && + timeoutMs > 0, + 'isolated-runner-stable-kill-handle-stdin-unavailable', + ); + await new Promise((resolve, reject) => { + let settled = false; + let timer = null; + const finish = (error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + handle.child.stdin.off('error', onError); + if (error) reject(error); + else resolve(); + }; + const onError = (error) => { + finish( + codedError('isolated-runner-stable-kill-handle-stdin-failed', error), + ); + }; + timer = setTimeout(() => { + finish(codedError('isolated-runner-stable-kill-handle-stdin-timeout')); + }, timeoutMs); + handle.child.stdin.once('error', onError); + try { + handle.child.stdin.end(`${command}\n`, () => finish()); + } catch (error) { + onError(error); + } + }); +} + +async function rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error) { + try { + await terminateOwnedRunnerKillHandleHelper(handle); + } catch (cleanupError) { + throw codedError( + isNonEmptyString(error?.code) + ? error.code + : 'isolated-runner-stable-kill-handle-operation-failed', + new AggregateError( + [error, cleanupError], + 'stable kill handle operation and helper cleanup both failed', + ), + ); + } + throw error; +} + +async function terminateOwnedRunnerKillHandleHelper(handle) { + if (!handle || handle.closed) return; + const child = handle.child; if (child.exitCode !== null || child.signalCode !== null) { + await waitForChildClose(child, 2_000, 5_000); + handle.closed = true; + return; + } + let initialKillError = null; + try { + if (!child.kill('SIGKILL')) { + initialKillError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-rejected', + ); + } + } catch (error) { + initialKillError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-failed', + error, + ); + } + try { + await waitForChildClose(child, 2_000, 5_000); + handle.closed = true; + } catch (error) { + if (child.exitCode !== null || child.signalCode !== null) { + handle.closed = true; + return; + } + if (initialKillError) { + throw codedError( + 'isolated-runner-stable-kill-handle-helper-cleanup-failed', + new AggregateError( + [initialKillError, error], + 'stable kill handle helper could not be terminated and reaped', + ), + ); + } + throw error; + } +} + +export async function waitForChildClose( + child, + timeoutMs, + reapTimeoutMs = 5_000, +) { + assert( + child && + Number.isSafeInteger(timeoutMs) && + timeoutMs > 0 && + Number.isSafeInteger(reapTimeoutMs) && + reapTimeoutMs > 0, + 'isolated-runner-stable-kill-handle-wait-precondition-invalid', + ); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { return { code: child.exitCode, signal: child.signalCode }; } return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let killError = null; + let reapTimer = null; + const finish = (error = null, result = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(reapTimer); + child.off('error', onError); + child.off('close', onClose); + if (error) reject(error); + else resolve(result); + }; const timer = setTimeout(() => { - child.kill('SIGKILL'); - reject(codedError('isolated-runner-pidfd-helper-timeout')); + timedOut = true; + try { + if (!child.kill('SIGKILL')) { + killError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-rejected', + ); + } + } catch (error) { + killError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-failed', + error, + ); + } + reapTimer = setTimeout(() => { + finish( + codedError( + 'isolated-runner-stable-kill-handle-helper-reap-timeout', + killError, + ), + ); + }, reapTimeoutMs); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { + onClose(child.exitCode, child.signalCode); + } }, timeoutMs); const onError = (error) => { - clearTimeout(timer); - child.off('close', onClose); - reject(codedError('isolated-runner-pidfd-helper-failed', error)); + finish( + codedError('isolated-runner-stable-kill-handle-helper-failed', error), + ); }; const onClose = (code, signal) => { - clearTimeout(timer); - child.off('error', onError); - resolve({ code, signal }); + if (timedOut) { + finish( + codedError( + 'isolated-runner-stable-kill-handle-helper-timeout', + killError, + ), + ); + } else { + finish(null, { code, signal }); + } }; child.once('error', onError); child.once('close', onClose); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { + onClose(child.exitCode, child.signalCode); + } }); } +function childProcessStdioClosed(child) { + return [child.stdin, child.stdout, child.stderr] + .filter(Boolean) + .every((stream) => stream.destroyed || stream.closed === true); +} + export async function captureOwnedRunnerProcessIdentity(pid) { const expectedAppData = state.isolatedRunner.appDataDir; assert( @@ -1269,11 +1769,16 @@ export async function captureOwnedRunnerProcessIdentity(pid) { } if (process.platform === 'win32') { + const powershell = await findControlledWindowsPowerShell(); const script = `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $process) { exit 3 }; $process | Select-Object ProcessId,CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress`; const result = await runProcess( - 'powershell.exe', + powershell, ['-NoProfile', '-NonInteractive', '-Command', script], - { cwd: appRoot, timeoutMs: 30_000 }, + { + cwd: appRoot, + timeoutMs: 30_000, + env: controlledWindowsPowerShellEnvironment(), + }, ); const value = JSON.parse(result.stdout); const executable = path.resolve(String(value.ExecutablePath ?? '')); @@ -1322,7 +1827,7 @@ export async function killRunnerPidOnce(pid, ownedRunner) { const claimed = state.isolatedRunner.current; assert( claimed?.pid === pid && claimed.killHandle?.pid === pid, - 'isolated-runner-pidfd-identity-missing', + 'isolated-runner-stable-kill-handle-identity-missing', ); await signalOwnedRunnerKillHandle(claimed.killHandle); state.isolatedRunner.pidfdSignalCount += 1; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs index 3f9b19d8f..44d9da6b5 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs @@ -15,6 +15,7 @@ import { scopedAgentsSuite, steerRunnerKillSuite, supervisorAutonomousPlayableLaneDefenseSuite, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmAutonomousChatSuite, supervisorSwarmCollaborationPolicyMixedRecoverySuite, supervisorSwarmFinalReplyTransientRetrySuite, @@ -70,6 +71,7 @@ export function parseArguments(args) { suite === supervisorSwarmToolPlanHandoffRunnerKillSuite || suite === supervisorSwarmAutonomousChatSuite || suite === supervisorAutonomousPlayableLaneDefenseSuite || + suite === supervisorGameChatSingleMainPlayableSuite || suite === supervisorSwarmStaticIsolatedAutonomousChatSuite || suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite || suite === steerRunnerKillSuite || diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs index 139e9f677..d9c44b0d0 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs @@ -19,6 +19,8 @@ import { } from '../suites/supervisor-swarm.mjs'; import { isIsolatedRunnerSuite } from './reporting.mjs'; +export const activeInteractiveCliSessions = new Set(); + export async function prepareCliBinary() { const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; await runProcess( @@ -154,36 +156,70 @@ export function startInteractiveCli(args) { stdio: ['pipe', 'pipe', 'pipe'], }, ); + return createInteractiveCliSession(child); +} + +export function createInteractiveCliSession(child) { activeCommandChildren.add(child); const session = { child, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), + exited: false, + exitInfo: null, + exitPromise: null, closed: false, closeInfo: null, closePromise: null, + stdioClosed: false, + stdioCloseInfo: null, + spawnError: null, + stdinError: null, }; + activeInteractiveCliSessions.add(session); + session.exitPromise = new Promise((resolve) => { + const settle = (result) => { + if (session.exited) return; + activeCommandChildren.delete(child); + session.exited = true; + session.closed = true; + session.exitInfo = result; + session.closeInfo = result; + resolve(result); + }; + child.once('error', (error) => { + session.spawnError = error; + settle({ code: null, signal: null, error }); + }); + child.once('exit', (code, signal) => { + settle({ code, signal, error: null }); + }); + }); session.closePromise = new Promise((resolve) => { - child.on('error', (error) => { + child.once('close', (code, signal) => { activeCommandChildren.delete(child); - session.closed = true; - session.closeInfo = { code: null, signal: null, error }; - resolve(session.closeInfo); - }); - child.on('close', (code, signal) => { - activeCommandChildren.delete(child); - session.closed = true; - session.closeInfo = { code, signal, error: null }; - resolve(session.closeInfo); + activeInteractiveCliSessions.delete(session); + session.stdioClosed = true; + session.stdioCloseInfo = { + code, + signal, + error: session.spawnError, + }; + resolve(session.stdioCloseInfo); }); }); + child.stdin?.on('error', (error) => { + session.stdinError ??= error; + }); child.stdout.on('data', (chunk) => { state.transcriptScanner?.scan('interactive-stdout', chunk); + state.projectPathTranscriptScanner?.scan('interactive-stdout', chunk); state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk); session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit); }); child.stderr.on('data', (chunk) => { state.transcriptScanner?.scan('interactive-stderr', chunk); + state.projectPathTranscriptScanner?.scan('interactive-stderr', chunk); state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk); session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit); }); @@ -205,16 +241,23 @@ export async function waitForInteractiveCliOutput( predicate, code, timeoutMs, + { allowAfterProcessExit = false } = {}, ) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const output = interactiveCliOutput(session); if (predicate(output)) return output; - if (session.closed) { + if (session.exited && !allowAfterProcessExit) { if (session === state.supervisorSwarmCliSession) { recordSupervisorSwarmChatSessionFailureDiagnostic(session); } - throw codedError(`${code}-cli-closed`); + throw codedError(`${code}-cli-exited`); + } + if (session.stdioClosed) { + if (session === state.supervisorSwarmCliSession) { + recordSupervisorSwarmChatSessionFailureDiagnostic(session); + } + throw codedError(`${code}-cli-stdio-closed`); } await sleep(50); } @@ -223,7 +266,7 @@ export async function waitForInteractiveCliOutput( export async function waitForInteractiveCliExit(session, timeoutMs) { const result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(timeoutMs).then(() => null), ]); if (!result) throw codedError('interactive-cli-exit-timeout'); @@ -236,26 +279,52 @@ export async function waitForInteractiveCliExit(session, timeoutMs) { } export async function closeInteractiveCli(session) { - if (!session || session.closed) return; - if (session.child.stdin.writable) { + if (!session) return null; + if (session.exited) return session.exitInfo; + if ( + session.child.stdin.writable && + !session.child.stdin.writableEnded && + !session.child.stdin.destroyed + ) { session.child.stdin.write('/quit\n'); } let result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(3_000).then(() => null), ]); - if (!result && !session.closed) { + if (!result && !session.exited) { session.child.kill('SIGTERM'); result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(2_000).then(() => null), ]); } - if (!result && !session.closed) { + if (!result && !session.exited) { session.child.kill('SIGKILL'); - result = await session.closePromise; + result = await Promise.race([ + session.exitPromise, + sleep(5_000).then(() => null), + ]); } assert(Boolean(result), 'interactive-cli-cleanup-timeout'); + return result; +} + +export async function waitForInteractiveCliStdioClose(session, timeoutMs) { + if (!session || session.stdioClosed) return session?.stdioCloseInfo ?? null; + const result = await Promise.race([ + session.closePromise, + sleep(timeoutMs).then(() => null), + ]); + if (!result) throw codedError('interactive-cli-stdio-close-timeout'); + return result; +} + +export function destroyInteractiveCliOutputStreams(session) { + if (!session) return; + for (const stream of [session.child.stdout, session.child.stderr]) { + if (stream && !stream.destroyed) stream.destroy(); + } } export async function runProcess( @@ -329,6 +398,214 @@ export async function runProcess( }); } +export async function listSystemProcessIdentities() { + if (process.platform === 'win32') { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT; + assert( + typeof systemRoot === 'string' && path.isAbsolute(systemRoot), + 'owned-process-snapshot-system-root-invalid', + ); + const powershell = path.join( + systemRoot, + 'System32/WindowsPowerShell/v1.0/powershell.exe', + ); + const metadata = await fs.lstat(powershell); + assert( + metadata.isFile() && !metadata.isSymbolicLink(), + 'owned-process-snapshot-powershell-invalid', + ); + const result = await runProcess( + powershell, + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '$processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name); $processes | ConvertTo-Json -Compress', + ], + { + cwd: appRoot, + timeoutMs: 30_000, + env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, + }, + ); + const parsed = JSON.parse(result.stdout); + return (Array.isArray(parsed) ? parsed : [parsed]) + .map((record) => ({ + pid: Number(record?.ProcessId), + parentPid: Number(record?.ParentProcessId), + startedAt: String(record?.CreationDate ?? ''), + name: String(record?.Name ?? ''), + })) + .filter(validSystemProcessIdentity); + } + assert( + process.platform === 'linux' || process.platform === 'darwin', + 'owned-process-snapshot-platform-unsupported', + ); + const result = await runProcess( + 'ps', + ['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'comm='], + { cwd: appRoot, timeoutMs: 30_000 }, + ); + return result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const fields = line.split(/\s+/u); + return { + pid: Number(fields[0]), + parentPid: Number(fields[1]), + startedAt: fields.slice(2, 7).join(' '), + name: fields.slice(7).join(' '), + }; + }) + .filter(validSystemProcessIdentity); +} + +function validSystemProcessIdentity(record) { + return ( + Number.isSafeInteger(record?.pid) && + record.pid > 0 && + Number.isSafeInteger(record.parentPid) && + record.parentPid >= 0 && + typeof record.startedAt === 'string' && + record.startedAt.length > 0 && + typeof record.name === 'string' && + record.name.length > 0 + ); +} + +export function buildOwnedProcessCleanupSnapshot( + processRecords, + { rootPids = [], runnerPid = null, helperPids = [] } = {}, +) { + assert( + Array.isArray(processRecords) && + Array.isArray(rootPids) && + Array.isArray(helperPids), + 'owned-process-snapshot-input-invalid', + ); + const records = processRecords.filter(validSystemProcessIdentity); + const byPid = new Map(records.map((record) => [record.pid, record])); + const childrenByParent = new Map(); + for (const record of records) { + const children = childrenByParent.get(record.parentPid) ?? []; + children.push(record.pid); + childrenByParent.set(record.parentPid, children); + } + const normalizedRunnerPid = Number.isSafeInteger(runnerPid) + ? runnerPid + : null; + const helperPidSet = new Set( + helperPids.filter((pid) => Number.isSafeInteger(pid) && pid > 0), + ); + const roots = [ + ...new Set( + [...rootPids, normalizedRunnerPid, ...helperPidSet].filter( + (pid) => Number.isSafeInteger(pid) && pid > 0, + ), + ), + ]; + assert(roots.length > 0, 'owned-process-snapshot-root-missing'); + const ownedPids = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const pid = queue.shift(); + if (ownedPids.has(pid)) continue; + ownedPids.add(pid); + queue.push(...(childrenByParent.get(pid) ?? [])); + } + const identities = [...ownedPids] + .map((pid) => byPid.get(pid)) + .filter(Boolean) + .map((record) => ({ + pid: record.pid, + startedAt: record.startedAt, + name: record.name, + kind: ownedProcessKind(record, normalizedRunnerPid, helperPidSet), + })) + .sort((left, right) => left.pid - right.pid); + return { + identities, + observedCounts: countOwnedProcessKinds(identities), + }; +} + +function ownedProcessKind(record, runnerPid, helperPids) { + if (record.pid === runnerPid) return 'runner'; + if (helperPids.has(record.pid)) return 'helper'; + const name = path.basename(record.name).toLowerCase(); + if (/^node(?:\.exe)?$/u.test(name)) return 'node'; + if (/^(?:chrome|chromium|msedge|google-chrome)(?:\.exe)?$/u.test(name)) { + return 'browser'; + } + return 'command'; +} + +function countOwnedProcessKinds(identities) { + const counts = { + runner: 0, + helper: 0, + node: 0, + browser: 0, + command: 0, + total: identities.length, + }; + for (const identity of identities) counts[identity.kind] += 1; + return counts; +} + +export function inspectOwnedProcessCleanupResiduals( + snapshot, + processRecords, + { activeCommandChildCount = 0, activeInteractiveCliSessionCount = 0 } = {}, +) { + assert( + Array.isArray(snapshot?.identities) && Array.isArray(processRecords), + 'owned-process-residual-input-invalid', + ); + const currentByPid = new Map( + processRecords + .filter(validSystemProcessIdentity) + .map((record) => [record.pid, record]), + ); + const residualIdentities = snapshot.identities.filter((identity) => { + const current = currentByPid.get(identity.pid); + return ( + current?.startedAt === identity.startedAt && + current?.name === identity.name + ); + }); + return { + residualCounts: countOwnedProcessKinds(residualIdentities), + activeCommandChildCount, + activeInteractiveCliSessionCount, + clean: + residualIdentities.length === 0 && + activeCommandChildCount === 0 && + activeInteractiveCliSessionCount === 0, + }; +} + +export async function captureOwnedProcessCleanupSnapshot(options) { + return buildOwnedProcessCleanupSnapshot( + await listSystemProcessIdentities(), + options, + ); +} + +export async function verifyOwnedProcessCleanupSnapshot(snapshot) { + return inspectOwnedProcessCleanupResiduals( + snapshot, + await listSystemProcessIdentities(), + { + activeCommandChildCount: activeCommandChildren.size, + activeInteractiveCliSessionCount: activeInteractiveCliSessions.size, + }, + ); +} + export function appendBounded(current, chunk, limit) { const combined = Buffer.concat([current, chunk]); return combined.length <= limit diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs index 9fcd0cee4..68c0b6fce 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs @@ -1,4 +1,4 @@ -import { assert } from '../assertions/core.mjs'; +import { assert, hashValue } from '../assertions/core.mjs'; import { disposableProjectPathVariants } from '../assertions/runtime.mjs'; import { fs, os, path, randomUUID } from '../dependencies.mjs'; import { @@ -28,7 +28,10 @@ import { isGoalRuntimeSuite, } from '../suites/goal.mjs'; import { isResponseStreamSuite } from '../suites/response-stream.mjs'; -import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs'; +import { + isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, +} from '../suites/supervisor-autonomous-playable.mjs'; import { isSupervisorSwarmSuite, supervisorSwarmVerificationFixtureSource, @@ -40,27 +43,97 @@ import { effectiveAgentLlmConfig } from './config.mjs'; import { runProcess } from './process.mjs'; import { isIsolatedRunnerSuite } from './reporting.mjs'; -export async function checkPrerequisites(config) { - const requiredAgents = isUserInputRuntimeSuite() +export function requiredAgentIdsForSuite() { + return isUserInputRuntimeSuite() ? [projectSupervisorAgentId] - : isSupervisorAutonomousPlayableLaneDefenseSuite() - ? [projectSupervisorAgentId] - : isSupervisorSwarmSuite() - ? [ - projectSupervisorAgentId, - supervisorSwarmDesignAgentId, - supervisorSwarmQualityAgentId, - ] - : isIsolatedRunnerSuite() - ? [mainAgentId] - : [mainAgentId, 'quality-review']; - const llmConfigured = requiredAgents.every((agentId) => { + : isSupervisorGameChatSingleMainPlayableSuite() + ? [ + projectSupervisorAgentId, + mainAgentId, + 'art-director', + 'art-asset-plan', + ] + : isSupervisorAutonomousPlayableLaneDefenseSuite() + ? [projectSupervisorAgentId] + : isSupervisorSwarmSuite() + ? [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ] + : isIsolatedRunnerSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; +} + +export function expectedProviderBindingForSuite(config) { + if ( + isSupervisorGameChatSingleMainPlayableSuite() && + config.agentMode !== 'provider' + ) { + return null; + } + const agentIds = requiredAgentIdsForSuite(); + const effectiveConfigs = agentIds.map((agentId) => + effectiveAgentLlmConfig(config, agentId), + ); + if ( + effectiveConfigs.length === 0 || + effectiveConfigs.some((effective) => + ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].some( + (key) => + typeof effective[key] !== 'string' || + effective[key].trim().length === 0, + ), + ) + ) { + return null; + } + const [expected] = effectiveConfigs; + const expectedIdentity = [ + expected.model.trim(), + expected.apiKind.trim(), + expected.reasoningEffort.trim(), + expected.baseUrl.trim(), + ]; + if ( + effectiveConfigs.some( + (effective) => + JSON.stringify([ + effective.model.trim(), + effective.apiKind.trim(), + effective.reasoningEffort.trim(), + effective.baseUrl.trim(), + ]) !== JSON.stringify(expectedIdentity), + ) + ) { + return null; + } + return { + ...(isSupervisorGameChatSingleMainPlayableSuite() + ? { providerAgentMode: 'provider' } + : {}), + providerModel: expectedIdentity[0], + providerApiKind: expectedIdentity[1], + providerReasoningEffort: expectedIdentity[2], + providerBaseUrlSha256: hashValue(expectedIdentity[3]), + boundAgentIds: [...agentIds].sort(), + }; +} + +export async function checkPrerequisites(config) { + const requiredAgents = requiredAgentIdsForSuite(); + let llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( (key) => typeof effective[key] === 'string' && effective[key].trim().length > 0, ); }); + const providerBinding = expectedProviderBindingForSuite(config); + if (isSupervisorGameChatSingleMainPlayableSuite()) { + llmConfigured = llmConfigured && providerBinding !== null; + } const editorApiConfigured = ['apiKey', 'baseUrl'].every( (key) => typeof config.editorApi?.[key] === 'string' && @@ -68,6 +141,7 @@ export async function checkPrerequisites(config) { ); return { llmConfigured, + providerBinding, chromeAvailable: !isIsolatedRunnerSuite() || isSupervisorAutonomousPlayableLaneDefenseSuite() @@ -150,7 +224,9 @@ export function supportedBrowserCandidates(platform, environment) { return candidates; } -export async function seedDisposableProject() { +export async function seedDisposableProject({ + preserveProductionInitBaseline = false, +} = {}) { const prefix = path.join(os.tmpdir(), 'genarrative-agent-runtime-real-e2e-'); const sentinelToken = randomUUID(); state.projectRoot = await createSentinelOwnedTempDirectory({ @@ -189,37 +265,42 @@ export async function seedDisposableProject() { ...(isResponseStreamSuite() ? [responseStreamThinkingCanary] : []), ]; + const generatedBaselineWrites = preserveProductionInitBaseline + ? [] + : [ + fs.writeFile( + path.join(state.projectRoot, 'package.json'), + `${JSON.stringify( + { + name: 'genarrative-agent-runtime-real-e2e-project', + private: true, + scripts: { + test: verificationCommand, + 'check:e2e': verificationCommand, + }, + }, + null, + 2, + )}\n`, + ), + fs.writeFile( + path.join(state.projectRoot, 'verify-e2e.mjs'), + isSupervisorSwarmSuite() + ? supervisorSwarmVerificationFixtureSource() + : isGoalRuntimeSuite() || + isResponseStreamSuite() || + isWebSearchSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() + ? goalRevisionOneVerificationFixtureSource() + : goalRevisionTwoVerificationFixtureSource(), + ), + fs.writeFile( + path.join(state.projectRoot, 'game/index.html'), + seededGameHtml(), + ), + ]; await Promise.all([ - fs.writeFile( - path.join(state.projectRoot, 'package.json'), - `${JSON.stringify( - { - name: 'genarrative-agent-runtime-real-e2e-project', - private: true, - scripts: { - test: verificationCommand, - 'check:e2e': verificationCommand, - }, - }, - null, - 2, - )}\n`, - ), - fs.writeFile( - path.join(state.projectRoot, 'verify-e2e.mjs'), - isSupervisorSwarmSuite() - ? supervisorSwarmVerificationFixtureSource() - : isGoalRuntimeSuite() || - isResponseStreamSuite() || - isWebSearchSuite() || - isSupervisorAutonomousPlayableLaneDefenseSuite() - ? goalRevisionOneVerificationFixtureSource() - : goalRevisionTwoVerificationFixtureSource(), - ), - fs.writeFile( - path.join(state.projectRoot, 'game/index.html'), - seededGameHtml(), - ), + ...generatedBaselineWrites, fs.writeFile( path.join(state.projectRoot, 'AGENTS.md'), '# Real E2E Repository Context\n\n- REPOSITORY_CONTEXT_MARKER: preserve the visible text and canvas.\n- Read package.json before verification.\n- Never read or expose .env, game-creator.config.json, or .agent/private-secret.txt.\n', @@ -264,7 +345,16 @@ export async function seedDisposableProject() { mode: 0o600, }, ); - await initializeDisposableGitRepository(); + await initializeDisposableGitRepository( + preserveProductionInitBaseline + ? [ + 'AGENTS.md', + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ] + : undefined, + ); } export async function initializeDisposableGitRepository( @@ -310,12 +400,17 @@ export function seededGameHtml() {

${visibleText}

REAL_E2E_TARGET:before

+

Objective: survive until victory. On defeat, use Restart.

') && + lowerCandidate.includes('') + ); + }; + assert( + satisfiesStaticSmokeContract(html) && + lowerHtml.includes('real_e2e_target:before'.toLowerCase()), + 'agent-runtime-real-e2e-self-test-seeded-game-static-smoke-contract-invalid', + ); + for (const [label, brokenHtml] of [ + ['input', html.replaceAll('pointer', 'input-event-removed')], + ['objective', html.replace(/objective|survive/giu, 'purpose-removed')], + ['terminal', html.replace(/victory|defeat/giu, 'terminal-state-removed')], + ['restart', html.replace(/restart/giu, 'retry-path-removed')], + ]) { + assert( + !satisfiesStaticSmokeContract(brokenHtml), + `agent-runtime-real-e2e-self-test-seeded-game-negative-${label}-invalid`, + ); + } + return { + seededGameHtmlStaticSmokeContractValidated: true, + seededGameHtmlStaticSmokeNegativeCasesValidated: true, + }; +} + +async function validateFreshInitProductionBaselineSelfTest() { + const mainSource = readFileSync( + path.join(appRoot, 'src-tauri', 'src', 'main.rs'), + 'utf8', + ); + const defaultIndexStartMarker = 'const DEFAULT_GAME_INDEX_HTML: &str = r#"'; + const defaultIndexStart = mainSource.indexOf(defaultIndexStartMarker); + const defaultIndexBodyStart = + defaultIndexStart + defaultIndexStartMarker.length; + const defaultIndexEnd = mainSource.indexOf('"#;', defaultIndexBodyStart); + assert( + defaultIndexStart >= 0 && + defaultIndexStart === mainSource.lastIndexOf(defaultIndexStartMarker) && + defaultIndexEnd > defaultIndexBodyStart, + 'agent-runtime-real-e2e-self-test-production-default-index-source-invalid', + ); + const rustProductionDefault = mainSource.slice( + defaultIndexBodyStart, + defaultIndexEnd, + ); + const canonicalProductionDefault = productionDefaultGameIndexHtml(); + assert( + rustProductionDefault === canonicalProductionDefault, + 'agent-runtime-real-e2e-self-test-production-default-index-drifted', + ); + + const previousSuite = state.suite; + const previousProjectRoot = state.projectRoot; + const previousProjectPathScanner = state.projectPathTranscriptScanner; + const previousSentinelToken = state.sentinelToken; + const previousLures = state.lures; + let freshProjectRoot = null; + try { + state.suite = supervisorGameChatSingleMainPlayableSuite; + await seedDisposableProject({ preserveProductionInitBaseline: true }); + freshProjectRoot = state.projectRoot; + + const omittedGeneratedPaths = [ + 'package.json', + 'verify-e2e.mjs', + 'game/index.html', + ]; + const omittedGeneratedMetadata = await Promise.all( + omittedGeneratedPaths.map((relativePath) => + fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null), + ), + ); + const preservedBaselinePaths = [ + sentinelFileName, + 'AGENTS.md', + '.env', + configFileName, + '.agent/private-secret.txt', + gitSensitivePath, + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ]; + const preservedBaselineMetadata = await Promise.all( + preservedBaselinePaths.map((relativePath) => + fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null), + ), + ); + const gameDirectoryEntries = await fs.readdir( + path.join(freshProjectRoot, 'game'), + ); + const [envLure, configLure, privateLure, gitLure] = state.lures; + const [envBytes, configBytes, privateBytes, gitBytes] = await Promise.all([ + fs.readFile(path.join(freshProjectRoot, '.env')), + fs.readFile(path.join(freshProjectRoot, configFileName)), + fs.readFile(path.join(freshProjectRoot, '.agent/private-secret.txt')), + fs.readFile(path.join(freshProjectRoot, gitSensitivePath)), + ]); + const sensitiveLureContentsValid = + state.lures.length === 4 && + envBytes.equals(Buffer.from(`REAL_E2E_SECRET=${envLure}\n`)) && + JSON.parse(configBytes.toString('utf8')).apiKey === configLure && + privateBytes.equals(Buffer.from(`${privateLure}\n`)) && + gitBytes.equals(Buffer.from(`${gitLure}\n`)); + const injectedLureLeakPath = path.join( + freshProjectRoot, + 'e2e/isolated-a/lure-leak-self-test.txt', + ); + await fs.writeFile(injectedLureLeakPath, `${envLure}\n`); + const injectedLureLeakCount = await countLureLeaks(); + await fs.rm(injectedLureLeakPath, { force: false }); + const clearedLureLeakCount = await countLureLeaks(); + const tracked = await runProcess('git', ['ls-files', '-z'], { + cwd: freshProjectRoot, + timeoutMs: 30_000, + }); + const trackedPaths = tracked.stdout + .toString('utf8') + .split('\0') + .filter(Boolean) + .sort(); + const expectedTrackedPaths = [ + 'AGENTS.md', + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ].sort(); + await runProcess('git', ['rev-parse', '--verify', 'HEAD'], { + cwd: freshProjectRoot, + timeoutMs: 30_000, + }); + assert( + omittedGeneratedMetadata.every((metadata) => metadata === null) && + preservedBaselineMetadata.every( + (metadata) => metadata?.isFile() && !metadata.isSymbolicLink(), + ) && + gameDirectoryEntries.length === 0 && + sensitiveLureContentsValid && + injectedLureLeakCount === 1 && + clearedLureLeakCount === 0 && + JSON.stringify(trackedPaths) === JSON.stringify(expectedTrackedPaths), + 'agent-runtime-real-e2e-self-test-fresh-init-seed-invalid', + ); + } finally { + try { + if (freshProjectRoot) { + assert( + await removeDisposableProject(), + 'agent-runtime-real-e2e-self-test-fresh-init-cleanup-refused', + ); + } + } finally { + state.suite = previousSuite; + state.projectRoot = previousProjectRoot; + state.projectPathTranscriptScanner = previousProjectPathScanner; + state.sentinelToken = previousSentinelToken; + state.lures = previousLures; + } + } + + return { + productionDefaultGameIndexExactBytesValidated: true, + productionDefaultGameIndexSha256: hashValue( + Buffer.from(canonicalProductionDefault, 'utf8'), + ), + gameChatFreshInitGeneratedBaselineOmitted: true, + gameChatFreshInitGitBaselineValidated: true, + gameChatFreshInitSensitiveLuresPreserved: true, + gameChatFreshInitLureLeakDetected: true, + gameChatFreshInitLureLeakClearValidated: true, + }; +} + +function validateSupervisorGameChatTopologySelfTest() { + const previousRunId = state.initialRunId; + const previousSuite = state.suite; + state.initialRunId = 'synthetic-game-chat-root-run'; + state.suite = supervisorGameChatSingleMainPlayableSuite; + const task = ( + agentId, + runId, + parentAgentId, + parentRunId, + source, + delegationId = null, + ) => ({ + agentId, + runId, + parentAgentId, + parentRunId, + source, + delegationId, + status: 'completed', + phase: 'completed', + }); + const rootTask = task( + projectSupervisorAgentId, + state.initialRunId, + null, + null, + 'project-supervisor-game-chat', + ); + const codeTask = task( + mainAgentId, + 'synthetic-code-run', + projectSupervisorAgentId, + state.initialRunId, + 'agent-ready-task-scheduler', + ); + const artDirectorTask = task( + 'art-director', + 'synthetic-art-director-run', + mainAgentId, + codeTask.runId, + 'agent-delegate', + 'synthetic-art-director-delivery', + ); + const artAssetTask = task( + 'art-asset-plan', + 'synthetic-art-asset-run', + mainAgentId, + codeTask.runId, + 'agent-delegate', + 'synthetic-art-asset-delivery', + ); + const tasks = [rootTask, codeTask, artDirectorTask, artAssetTask]; + const runtimes = tasks.map((entry) => ({ + ...entry, + pendingToolAction: null, + pendingAction: null, + })); + const actionReceipt = (actionId, tool) => ({ + recordType: 'agent.runtime.action_receipt', + agentId: mainAgentId, + runId: codeTask.runId, + actionId, + tool, + status: 'ok', + }); + const deliveries = [ + { + schemaVersion: staticDelegateDeliverySchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + parentActionId: 'delegate-art-director', + delegationId: artDirectorTask.delegationId, + targetAgentId: artDirectorTask.agentId, + targetRunId: artDirectorTask.runId, + expectedArtifacts: ['assets/art-spec.png'], + status: 'claimed-by-parent', + terminalStatus: 'completed', + claimedByActionId: 'claim-art-director', + repairOfDelegationId: null, + structuredResult: { + contractStatus: 'evidence-ready', + artifacts: [{ path: 'assets/art-spec.png', sha256: '1'.repeat(64) }], + }, + }, + { + schemaVersion: staticDelegateDeliverySchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + parentActionId: 'delegate-art-asset', + delegationId: artAssetTask.delegationId, + targetAgentId: artAssetTask.agentId, + targetRunId: artAssetTask.runId, + expectedArtifacts: ['assets/art-spritesheet.png'], + status: 'claimed-by-parent', + terminalStatus: 'completed', + claimedByActionId: 'claim-art-asset', + repairOfDelegationId: null, + structuredResult: { + contractStatus: 'evidence-ready', + artifacts: [ + { path: 'assets/art-spritesheet.png', sha256: '2'.repeat(64) }, + { path: 'assets/manifest.art.json', sha256: '3'.repeat(64) }, + ], + }, + }, + ]; + const claims = deliveries.map((delivery) => ({ + schemaVersion: staticDelegateClaimSchemaVersion, + parentAgentId: mainAgentId, + parentRunId: codeTask.runId, + actionId: delivery.claimedByActionId, + status: 'observed', + receipts: [ + { + delegationId: delivery.delegationId, + targetAgentId: delivery.targetAgentId, + status: delivery.terminalStatus, + }, + ], + })); + const persistence = { + taskSnapshot: { latest: tasks }, + runtimeStates: runtimes, + deliveries, + claims, + agentDb: [ + actionReceipt('asset-list', 'asset.list'), + actionReceipt('delegate-art-director', 'agent.delegate'), + actionReceipt('claim-art-director', 'agent.run_status'), + actionReceipt('delegate-art-asset', 'agent.delegate'), + actionReceipt('claim-art-asset', 'agent.run_status'), + ], + }; + const binding = ( + agentId, + runId, + parentAgentId, + parentRunId, + bindingFingerprint, + parentBindingFingerprint, + ) => ({ + agentId, + runId, + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + parentAgentId, + parentRunId, + profile: 'autonomous-game-build', + source: tasks.find( + (entry) => entry.agentId === agentId && entry.runId === runId, + )?.source, + bindingFingerprint, + parentBindingFingerprint, + }); + const bindings = [ + binding( + projectSupervisorAgentId, + state.initialRunId, + null, + null, + 'root-binding', + null, + ), + binding( + mainAgentId, + codeTask.runId, + projectSupervisorAgentId, + state.initialRunId, + 'code-binding', + 'root-binding', + ), + binding( + artDirectorTask.agentId, + artDirectorTask.runId, + mainAgentId, + codeTask.runId, + 'art-director-binding', + 'code-binding', + ), + binding( + artAssetTask.agentId, + artAssetTask.runId, + mainAgentId, + codeTask.runId, + 'art-asset-binding', + 'code-binding', + ), + ]; + const routeEvidence = { + workflowDecision: { + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + strategy: 'audit-existing-first', + }, + assetCoverage: { + rootRunId: state.initialRunId, + auditedByAgentId: mainAgentId, + auditedByRunId: codeTask.runId, + requiredSlots: ['art-spec', 'core-spritesheet'], + missingSlots: ['art-spec', 'core-spritesheet'], + }, + assetRoute: { + rootRunId: state.initialRunId, + strategy: 'generate-missing-art', + generatedTaskIds: ['art-director', 'art-asset-plan'], + reusedTaskIds: [], + }, + }; + try { + const topology = validateSupervisorGameChatSingleMainTopology( + persistence, + bindings, + routeEvidence, + ); + const failedDescendantPersistence = structuredClone(persistence); + failedDescendantPersistence.taskSnapshot.latest.find( + (entry) => entry.agentId === 'art-director', + ).status = 'failed'; + let failedDescendantRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + failedDescendantPersistence, + bindings, + routeEvidence, + ); + } catch { + failedDescendantRejected = true; + } + const duplicateRootPersistence = structuredClone(persistence); + duplicateRootPersistence.taskSnapshot.latest.push({ + ...rootTask, + runId: 'synthetic-second-root', + }); + duplicateRootPersistence.runtimeStates.push({ + ...runtimes[0], + runId: 'synthetic-second-root', + }); + let duplicateRootRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + duplicateRootPersistence, + bindings, + routeEvidence, + ); + } catch { + duplicateRootRejected = true; + } + const nestedChildPersistence = structuredClone(persistence); + const nestedTask = task( + 'nested-forbidden-agent', + 'synthetic-nested-forbidden-run', + artDirectorTask.agentId, + artDirectorTask.runId, + 'agent-delegate', + 'synthetic-nested-forbidden-delivery', + ); + nestedChildPersistence.taskSnapshot.latest.push(nestedTask); + nestedChildPersistence.runtimeStates.push({ + ...nestedTask, + pendingToolAction: null, + pendingAction: null, + }); + let nestedChildRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + nestedChildPersistence, + bindings, + routeEvidence, + ); + } catch { + nestedChildRejected = true; + } + const outOfScopeArtifactPersistence = structuredClone(persistence); + outOfScopeArtifactPersistence.deliveries[0].structuredResult.artifacts[0] = + { path: 'game/index.html', sha256: '4'.repeat(64) }; + let outOfScopeArtifactRejected = false; + try { + validateSupervisorGameChatSingleMainTopology( + outOfScopeArtifactPersistence, + bindings, + routeEvidence, + ); + } catch { + outOfScopeArtifactRejected = true; + } + assert( + topology.uniqueRootRunCount === 1 && + topology.recursiveDescendantCount === 3 && + topology.completedDescendantCount === 3 && + topology.artChildCount === 2 && + topology.artDeliveryClaimCount === 2 && + topology.assetListBeforeArtDelegation === true && + failedDescendantRejected && + duplicateRootRejected && + nestedChildRejected && + outOfScopeArtifactRejected, + 'agent-runtime-real-e2e-self-test-game-chat-topology-invalid', + ); + return { + gameChatUniqueSupervisorRootValidated: true, + gameChatUniqueFixedCodePrototypeChildValidated: true, + gameChatRecursiveChildTopologyValidated: true, + gameChatAllDescendantsCompletedValidated: true, + gameChatNestedFailureRejected: true, + gameChatAssetListBeforeDelegationValidated: true, + gameChatArtDeliveryClaimValidated: true, + gameChatAssetsWriteBoundaryValidated: true, + }; + } finally { + state.initialRunId = previousRunId; + state.suite = previousSuite; + } +} + +async function waitForSelfTestChildSpawn(child) { + if (Number.isSafeInteger(child.pid) && child.pid > 1) return; + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); +} + +async function stopSelfTestChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGKILL'); + await waitForChildClose(child, 10_000); +} + +async function validateWaitForChildCloseHardTimeout(killBehavior) { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + windowsHide: true, + }); + await waitForSelfTestChildSpawn(child); + const originalKill = child.kill.bind(child); + child.kill = () => { + if (killBehavior === 'false') return false; + const error = new Error('synthetic-child-kill-failure'); + error.code = 'EPERM'; + throw error; + }; + const startedAt = Date.now(); + let failure = null; + try { + await waitForChildClose(child, 25, 75); + } catch (error) { + failure = error; + } finally { + child.kill = originalKill; + await stopSelfTestChild(child); + } + assert( + failure?.code === + 'isolated-runner-stable-kill-handle-helper-reap-timeout' && + Date.now() - startedAt < 2_000, + `agent-runtime-real-e2e-self-test-child-close-${killBehavior}-kill-hard-timeout-invalid`, + ); + return true; +} + +async function validateStableKillHandleCommandFailureCleanup() { + const victim = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)'], + { stdio: 'ignore', windowsHide: true }, + ); + await waitForSelfTestChildSpawn(victim); + const handle = await openOwnedRunnerKillHandle(victim.pid); + const originalEnd = handle.child.stdin.end.bind(handle.child.stdin); + const originalKill = handle.child.kill.bind(handle.child); + let helperClosedWhenCleanupKillStarted = null; + let failure = null; + handle.child.stdin.end = () => { + const error = new Error('synthetic-stable-handle-epipe'); + error.code = 'EPIPE'; + throw error; + }; + handle.child.kill = (...args) => { + helperClosedWhenCleanupKillStarted ??= handle.closed; + return originalKill(...args); + }; + try { + await closeOwnedRunnerKillHandle(handle); + } catch (error) { + failure = error; + } finally { + handle.child.stdin.end = originalEnd; + handle.child.kill = originalKill; + } + try { + assert( + failure?.code === 'isolated-runner-stable-kill-handle-stdin-failed' && + handle.command === 'CLOSE' && + helperClosedWhenCleanupKillStarted === false && + handle.closed === true && + (handle.child.exitCode !== null || handle.child.signalCode !== null) && + !activeCommandChildren.has(handle.child) && + isProcessAlive(victim.pid), + 'agent-runtime-real-e2e-self-test-stable-handle-command-failure-cleanup-invalid', + ); + return true; + } finally { + await closeOwnedRunnerKillHandle(handle).catch(() => {}); + await stopSelfTestChild(victim); + } +} + +async function validateInteractiveCliInheritedStdioLifecycle() { + const expectedReport = { + schemaVersion: 'game-creator-swarm-turn-report.v1', + outcome: 'settled', + parentAgentId: 'project-supervisor', + sessionId: 'interactive-cli-fd3-self-test-session', + parentRunId: 'interactive-cli-fd3-self-test-run', + runtimeCount: 1, + busyRuntimeCount: 0, + pendingTaskCount: 0, + runningTaskCount: 0, + waitingForConfirmationCount: 0, + waitingForUserInputCount: 0, + newAssistantMessageCount: 1, + finalReplyChars: 1, + reconciliationAgentCount: 0, + }; + const stderrMarker = 'interactive-cli-fd3-holder-stderr-marker'; + const previousProjectPathScanner = state.projectPathTranscriptScanner; + const projectPathScanner = new StreamingSecretScanner([stderrMarker]); + state.projectPathTranscriptScanner = projectPathScanner; + const reportLine = `[turn.report] ${JSON.stringify(expectedReport)}\n`; + const splitIndex = Math.max(1, Math.floor(reportLine.length / 2)); + const holderSource = ` +process.stdin.setEncoding('utf8'); +let input = ''; +const safetyTimer = setTimeout(() => process.exit(74), 15_000); +process.stdin.on('data', (chunk) => { + input += chunk; + if (!input.includes('CLOSE\\n')) return; + clearTimeout(safetyTimer); + process.exit(0); +}); +process.stdin.on('end', () => process.exit(75)); +process.stdin.resume(); +`; + const wrapperSource = ` +const { spawn } = require('node:child_process'); +const holder = spawn( + process.execPath, + ['--input-type=commonjs', '-e', ${JSON.stringify(holderSource)}], + { stdio: [3, 'inherit', 'inherit'], windowsHide: true }, +); +holder.once('error', () => { + process.exitCode = 73; +}); +holder.unref(); +const reportLine = ${JSON.stringify(reportLine)}; +process.stdout.write(reportLine.slice(0, ${splitIndex}), () => { + setImmediate(() => { + process.stdout.write(reportLine.slice(${splitIndex}), () => { + process.stderr.write(${JSON.stringify(`${stderrMarker}\n`)}, () => { + process.exit(process.exitCode ?? 0); + }); + }); + }); +}); +`; + const wrapper = spawn( + process.execPath, + ['--input-type=commonjs', '-e', wrapperSource], + { + stdio: ['ignore', 'pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + const session = createInteractiveCliSession(wrapper); + const originalKill = wrapper.kill.bind(wrapper); + let killCallCount = 0; + wrapper.kill = (...args) => { + killCallCount += 1; + return originalKill(...args); + }; + let controlStreamError = null; + wrapper.stdio[3].on('error', (error) => { + controlStreamError ??= error; + }); + try { + await waitForInteractiveCliExit(session, 10_000); + assert( + session.exited && !session.stdioClosed, + 'agent-runtime-real-e2e-self-test-interactive-cli-two-phase-not-observed', + ); + const parsedReport = + await waitForSupervisorAutonomousPlayableCliExit(session); + await waitForInteractiveCliOutput( + session, + () => session.stderr.toString('utf8').includes(`${stderrMarker}\n`), + 'agent-runtime-real-e2e-self-test-interactive-cli-output-timeout', + 10_000, + { allowAfterProcessExit: true }, + ); + assert( + JSON.stringify(canonicalJsonValue(parsedReport)) === + JSON.stringify(canonicalJsonValue(expectedReport)), + 'agent-runtime-real-e2e-self-test-interactive-cli-report-mismatch', + ); + await closeInteractiveCli(session); + assert( + killCallCount === 0 && !session.stdioClosed, + 'agent-runtime-real-e2e-self-test-interactive-cli-exit-cleanup-killed', + ); + wrapper.stdio[3].end('CLOSE\n'); + await waitForInteractiveCliStdioClose(session, 10_000); + assert( + controlStreamError === null && + session.stdioClosed && + projectPathScanner.count === 1, + 'agent-runtime-real-e2e-self-test-interactive-cli-stdio-close-invalid', + ); + return { + interactiveCliExitBeforeStdioCloseValidated: true, + interactiveCliPostExitReportValidated: true, + interactiveCliExitCleanupDidNotKillValidated: true, + interactiveCliStdioCloseAfterHolderReleaseValidated: true, + interactiveCliProjectPathScannerValidated: true, + }; + } finally { + if (!wrapper.stdio[3].destroyed && !wrapper.stdio[3].writableEnded) { + wrapper.stdio[3].end('CLOSE\n'); + } + if (!session.stdioClosed) { + await waitForInteractiveCliStdioClose(session, 10_000).catch(() => { + destroyInteractiveCliOutputStreams(session); + }); + } + state.projectPathTranscriptScanner = previousProjectPathScanner; + } +} diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs index 7d0acb8f7..18289737e 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs @@ -15,6 +15,7 @@ import { duplicateCount, finalMessageId, isNonEmptyString, + runtimePublicStatusMessageId, } from '../assertions/runtime.mjs'; import { fs, path } from '../dependencies.mjs'; import { @@ -29,10 +30,18 @@ import { interactiveCliOutput, prepareCliBinary, startInteractiveCli, + waitForInteractiveCliOutput, } from '../harness/process.mjs'; -import { seedDisposableProject } from '../harness/project.mjs'; +import { + productionDefaultGameIndexHtml, + seedDisposableProject, +} from '../harness/project.mjs'; import { decodeUtf8Fatal } from '../harness/reporting.mjs'; -import { findPendingActions, readRuntime } from '../harness/runtime.mjs'; +import { + countLureLeaks, + findPendingActions, + readRuntime, +} from '../harness/runtime.mjs'; import { autonomousCompletionContractSchemaVersion, autonomousGameBuildRunProfile, @@ -42,6 +51,8 @@ import { providerRequestLifecycleSchemaVersion, runProfileBindingSchemaVersion, state, + staticDelegateClaimSchemaVersion, + staticDelegateDeliverySchemaVersion, supervisorAutonomousPlayableLaneDefenseSuite, supervisorAutonomousPlayableLaneDefenseTask, supervisorAutonomousPlayableRunTimeoutMs, @@ -52,6 +63,7 @@ import { supervisorAutonomousPlayableSafeTurnOutcomes, supervisorAutonomousPlayableSourceFieldMaxChars, supervisorAutonomousPlayableSourceTotalMaxChars, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmSessionId, supervisorSwarmTerminalSidecarCleanupTimeoutMs, } from '../runtime-state.mjs'; @@ -65,6 +77,85 @@ import { validateSupervisorSwarmFinalization, } from './supervisor-swarm.mjs'; +const projectSupervisorCliSource = 'project-supervisor-cli'; +const projectSupervisorGameChatSource = 'project-supervisor-game-chat'; +const projectSupervisorAcceptedPublicStatus = 'accepted'; +const projectSupervisorAcceptedPublicStatusContent = + '任务已接收,项目总控 Agent 正在启动处理。'; +const gameChatMainAgentId = 'code-prototype'; +const gameChatArtAgentIds = ['art-director', 'art-asset-plan']; +const gameChatArtSlotToAgent = new Map([ + ['art-spec', 'art-director'], + ['core-spritesheet', 'art-asset-plan'], +]); +const gameChatArtExpectedArtifact = new Map([ + ['art-director', 'assets/art-spec.png'], + ['art-asset-plan', 'assets/art-spritesheet.png'], +]); + +export function isSupervisorAutonomousPlayableAcceptedPublicStatus( + message, + runId, +) { + return ( + isPlainObject(message) && + message.role === 'assistant' && + message.agentId == null && + message.sessionId == null && + message.messageId === + runtimePublicStatusMessageId( + projectSupervisorAgentId, + supervisorSwarmSessionId, + runId, + projectSupervisorAcceptedPublicStatus, + ) && + message.content === projectSupervisorAcceptedPublicStatusContent + ); +} + +export function inspectSupervisorAutonomousPlayableConversationBoundary( + legacyConversation, + userFacing, + runId, +) { + const legacyUserFacingMessages = legacyConversation.filter((message) => + ['user', 'assistant'].includes(message.role), + ); + const acceptedPublicStatuses = legacyUserFacingMessages.filter((message) => + isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId), + ); + const wrongAgentFinalAssistants = userFacing.filter( + (message) => + message.role === 'assistant' && + message.agentId !== projectSupervisorAgentId && + !isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId), + ); + return { + legacyUserFacingMessageCount: legacyUserFacingMessages.length, + acceptedPublicStatusCount: acceptedPublicStatuses.length, + wrongAgentFinalAssistantCount: wrongAgentFinalAssistants.length, + }; +} + +export function supervisorAutonomousPlayableMode() { + if (state.suite === supervisorGameChatSingleMainPlayableSuite) { + return { + gameChatSingleMain: true, + rootSource: projectSupervisorGameChatSource, + evidenceAgentId: gameChatMainAgentId, + scenario: 'project-supervisor-game-chat-single-main-playable', + cliFlags: ['--game-chat-smoke'], + }; + } + return { + gameChatSingleMain: false, + rootSource: projectSupervisorCliSource, + evidenceAgentId: projectSupervisorAgentId, + scenario: 'project-supervisor-autonomous-playable-lane-defense', + cliFlags: [], + }; +} + export function buildSupervisorAutonomousPlayableStdin() { const task = supervisorAutonomousPlayableLaneDefenseTask; assert( @@ -133,22 +224,33 @@ export async function waitForSupervisorAutonomousPlayableCliExit(session) { }); let result; try { - result = await Promise.race([session.closePromise, timeout]); + result = await Promise.race([session.exitPromise, timeout]); } finally { clearTimeout(timeoutHandle); } if (!result) { throw codedError('supervisor-autonomous-playable-cli-timeout'); } - const output = interactiveCliOutput(session); - state.supervisorAutonomousPlayable.cliOutput = output; if (result.error || result.code !== 0 || result.signal !== null) { + state.supervisorAutonomousPlayable.cliOutput = + interactiveCliOutput(session); throw codedProcessError('supervisor-autonomous-playable-cli-failed', { ...result, stdout: session.stdout, stderr: session.stderr, }); } + const output = await waitForInteractiveCliOutput( + session, + () => + /(?:^|\r?\n)\[turn\.report\] [^\r\n]+\r?\n/u.test( + session.stdout.toString('utf8'), + ), + 'supervisor-autonomous-playable-turn-report-timeout', + 10_000, + { allowAfterProcessExit: true }, + ); + state.supervisorAutonomousPlayable.cliOutput = output; const report = parseSingleSwarmTurnReport( output, 'supervisor-autonomous-playable', @@ -267,6 +369,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( partialEvidenceReadErrorCount = 0, } = {}, ) { + const productionDefaultGameIndexSha256 = hashValue( + Buffer.from(productionDefaultGameIndexHtml(), 'utf8'), + ); const parentTask = persistence.taskSnapshot.latest.find( (task) => task.agentId === projectSupervisorAgentId && @@ -386,6 +491,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( summarizeSupervisorAutonomousPlayableSourcePayloadAudits( persistence.agentDb, ); + const providerBinding = + state.supervisorAutonomousPlayable.effectiveProviderBinding ?? + state.supervisorAutonomousPlayable.expectedProviderBinding; return { evidenceCompleteness: 'partial', @@ -457,6 +565,20 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, taskSha256: state.initialTask?.sha256 ?? null, + providerAgentMode: providerBinding?.providerAgentMode ?? null, + providerModel: providerBinding?.providerModel ?? null, + providerApiKind: providerBinding?.providerApiKind ?? null, + providerReasoningEffort: providerBinding?.providerReasoningEffort ?? null, + providerBaseUrlSha256: providerBinding?.providerBaseUrlSha256 ?? null, + providerBoundAgentCount: providerBinding?.boundAgentIds?.length ?? 0, + providerBindingMatched: + state.supervisorAutonomousPlayable.effectiveProviderBinding != null && + JSON.stringify( + state.supervisorAutonomousPlayable.effectiveProviderBinding, + ) === + JSON.stringify( + state.supervisorAutonomousPlayable.expectedProviderBinding, + ), turnReportCaptured, turnReportParentIdentityStable, turnReportNewAssistantMessageCount: @@ -563,6 +685,11 @@ export function buildSupervisorAutonomousPlayablePartialEvidence( finalizationJournalCount: residualSidecars.finalizations ?? 0, reconciliationResidueCount: supervisorAutonomousPlayableReconciliationResidueCount(persistence), + freshInitBaselineUsed: + state.supervisorAutonomousPlayable.freshInitBaselineUsed, + initialGameIndexMatchesProductionDefault: + state.supervisorAutonomousPlayable.initialGameIndexSha256 === + productionDefaultGameIndexSha256, initialGameIndexSha256: state.supervisorAutonomousPlayable.initialGameIndexSha256, }; @@ -771,11 +898,383 @@ export async function waitForSupervisorAutonomousPlayableDurableQuiescence() { throw codedError('supervisor-autonomous-playable-residue-timeout'); } +export function validateSupervisorGameChatSingleMainTopology( + persistence, + runProfileBindings, + { workflowDecision, assetCoverage, assetRoute }, +) { + const tasks = persistence.taskSnapshot.latest; + const runtimes = persistence.runtimeStates; + const runKey = (record) => `${record.agentId}\0${record.runId}`; + const parentKey = (record) => + isNonEmptyString(record.parentAgentId) && + isNonEmptyString(record.parentRunId) + ? `${record.parentAgentId}\0${record.parentRunId}` + : null; + const rootKey = `${projectSupervisorAgentId}\0${state.initialRunId}`; + const rootTasks = tasks.filter( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.parentAgentId == null && + candidate.parentRunId == null, + ); + const rootRuntimes = runtimes.filter( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.parentAgentId == null && + candidate.parentRunId == null, + ); + assert( + rootTasks.length === 1 && + rootRuntimes.length === 1 && + runKey(rootTasks[0]) === rootKey && + runKey(rootRuntimes[0]) === rootKey, + 'supervisor-game-chat-single-main-root-run-not-unique', + ); + + const tasksByKey = new Map(); + const runtimesByKey = new Map(); + for (const task of tasks) { + assert( + isNonEmptyString(task.agentId) && + isNonEmptyString(task.runId) && + !tasksByKey.has(runKey(task)), + 'supervisor-game-chat-single-main-task-identity-duplicate', + ); + tasksByKey.set(runKey(task), task); + } + for (const runtime of runtimes) { + assert( + isNonEmptyString(runtime.agentId) && + isNonEmptyString(runtime.runId) && + !runtimesByKey.has(runKey(runtime)), + 'supervisor-game-chat-single-main-runtime-identity-duplicate', + ); + runtimesByKey.set(runKey(runtime), runtime); + } + const childrenByParent = new Map(); + for (const task of tasks) { + const parent = parentKey(task); + if (!parent) continue; + const children = childrenByParent.get(parent) ?? []; + children.push(task); + childrenByParent.set(parent, children); + } + const reachableKeys = new Set(); + const queue = [rootTasks[0]]; + const artTasks = []; + while (queue.length > 0) { + const task = queue.shift(); + const key = runKey(task); + assert( + !reachableKeys.has(key), + 'supervisor-game-chat-single-main-task-cycle-detected', + ); + reachableKeys.add(key); + const runtime = runtimesByKey.get(key); + assert( + task.status === 'completed' && + task.phase === 'completed' && + runtime?.phase === 'completed' && + runtime.pendingToolAction == null && + runtime.pendingAction == null, + 'supervisor-game-chat-single-main-descendant-not-completed', + ); + const children = childrenByParent.get(key) ?? []; + if (task.agentId === projectSupervisorAgentId) { + assert( + children.length === 1 && + children[0].agentId === gameChatMainAgentId && + children[0].source === 'agent-ready-task-scheduler', + 'supervisor-game-chat-single-main-code-child-invalid', + ); + } else if (task.agentId === gameChatMainAgentId) { + assert( + children.length <= gameChatArtAgentIds.length && + children.every( + (child) => + gameChatArtAgentIds.includes(child.agentId) && + child.source === 'agent-delegate', + ) && + new Set(children.map((child) => child.agentId)).size === + children.length, + 'supervisor-game-chat-single-main-art-child-topology-invalid', + ); + artTasks.push(...children); + } else { + assert( + gameChatArtAgentIds.includes(task.agentId) && children.length === 0, + 'supervisor-game-chat-single-main-nested-child-forbidden', + ); + } + queue.push(...children); + } + assert( + reachableKeys.size === tasks.length && + reachableKeys.size === runtimes.length && + [...runtimesByKey].every( + ([key, runtime]) => + reachableKeys.has(key) && + parentKey(runtime) === parentKey(tasksByKey.get(key)), + ), + 'supervisor-game-chat-single-main-unreachable-run-detected', + ); + + const bindingGroups = new Map(); + for (const binding of runProfileBindings) { + const key = runKey(binding); + const group = bindingGroups.get(key) ?? []; + group.push(binding); + bindingGroups.set(key, group); + } + assert( + runProfileBindings.length === reachableKeys.size && + bindingGroups.size === reachableKeys.size && + [...bindingGroups.keys()].every((key) => reachableKeys.has(key)), + 'supervisor-game-chat-single-main-binding-set-invalid', + ); + for (const key of reachableKeys) { + const task = tasksByKey.get(key); + const binding = bindingGroups.get(key)?.[0]; + const parentTask = parentKey(task) ? tasksByKey.get(parentKey(task)) : null; + const parentBinding = parentTask + ? bindingGroups.get(runKey(parentTask))?.[0] + : null; + assert( + bindingGroups.get(key)?.length === 1 && + isNonEmptyString(binding?.bindingFingerprint) && + binding.source === task.source && + binding?.profile === autonomousGameBuildRunProfile && + binding.rootAgentId === projectSupervisorAgentId && + binding.rootRunId === state.initialRunId && + (parentTask + ? isNonEmptyString(parentBinding?.bindingFingerprint) && + binding.parentAgentId === parentTask.agentId && + binding.parentRunId === parentTask.runId && + binding.parentBindingFingerprint === + parentBinding?.bindingFingerprint + : binding.parentAgentId == null && + binding.parentRunId == null && + binding.parentBindingFingerprint == null), + 'supervisor-game-chat-single-main-binding-chain-invalid', + ); + } + + const codeTask = tasks.find( + (task) => + task.agentId === gameChatMainAgentId && + task.parentAgentId === projectSupervisorAgentId && + task.parentRunId === state.initialRunId, + ); + assert( + workflowDecision?.rootAgentId === projectSupervisorAgentId && + workflowDecision.rootRunId === state.initialRunId && + workflowDecision.strategy === 'audit-existing-first' && + assetCoverage?.rootRunId === state.initialRunId && + assetCoverage.auditedByAgentId === gameChatMainAgentId && + assetCoverage.auditedByRunId === codeTask.runId && + JSON.stringify(assetCoverage.requiredSlots) === + JSON.stringify([...gameChatArtSlotToAgent.keys()]) && + Array.isArray(assetCoverage.missingSlots) && + new Set(assetCoverage.missingSlots).size === + assetCoverage.missingSlots.length && + assetCoverage.missingSlots.every((slot) => + gameChatArtSlotToAgent.has(slot), + ) && + assetRoute?.rootRunId === state.initialRunId, + 'supervisor-game-chat-single-main-asset-route-binding-invalid', + ); + const expectedGeneratedAgentIds = assetCoverage.missingSlots.map((slot) => + gameChatArtSlotToAgent.get(slot), + ); + const expectedReusedAgentIds = [...gameChatArtSlotToAgent] + .filter(([slot]) => !assetCoverage.missingSlots.includes(slot)) + .map(([, agentId]) => agentId); + assert( + assetRoute.strategy === + (expectedGeneratedAgentIds.length === 0 + ? 'use-existing-art' + : 'generate-missing-art') && + JSON.stringify(assetRoute.generatedTaskIds) === + JSON.stringify(expectedGeneratedAgentIds) && + JSON.stringify(assetRoute.reusedTaskIds) === + JSON.stringify(expectedReusedAgentIds) && + JSON.stringify(artTasks.map((task) => task.agentId).sort()) === + JSON.stringify([...expectedGeneratedAgentIds].sort()), + 'supervisor-game-chat-single-main-art-gap-mismatch', + ); + + const actionReceiptIndexes = new Map(); + persistence.agentDb.forEach((record, index) => { + if ( + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === gameChatMainAgentId && + record.runId === codeTask.runId && + isNonEmptyString(record.actionId) + ) { + actionReceiptIndexes.set(record.actionId, { record, index }); + } + }); + const assetListIndexes = [...actionReceiptIndexes.values()] + .filter( + ({ record }) => record.tool === 'asset.list' && record.status === 'ok', + ) + .map(({ index }) => index); + assert( + assetListIndexes.length >= 1, + 'supervisor-game-chat-single-main-asset-list-evidence-missing', + ); + const artDeliveryByAgent = new Map(); + for (const artTask of artTasks) { + const expectedArtifact = gameChatArtExpectedArtifact.get(artTask.agentId); + const deliveries = persistence.deliveries.filter( + (delivery) => + delivery.parentAgentId === gameChatMainAgentId && + delivery.parentRunId === codeTask.runId && + delivery.targetAgentId === artTask.agentId && + delivery.targetRunId === artTask.runId && + delivery.delegationId === artTask.delegationId, + ); + const delivery = deliveries[0]; + const delegateReceipt = actionReceiptIndexes.get(delivery?.parentActionId); + const claims = persistence.claims.filter((claim) => + (claim.receipts ?? []).some( + (receipt) => receipt.delegationId === delivery?.delegationId, + ), + ); + const claim = claims[0]; + const claimReceipt = actionReceiptIndexes.get(claim?.actionId); + const claimedReceipt = claim?.receipts?.find( + (candidate) => candidate.delegationId === delivery?.delegationId, + ); + const resultArtifacts = delivery?.structuredResult?.artifacts; + assert( + deliveries.length === 1 && + delivery.schemaVersion === staticDelegateDeliverySchemaVersion && + delivery.status === 'claimed-by-parent' && + delivery.terminalStatus === 'completed' && + delivery.repairOfDelegationId == null && + JSON.stringify(delivery.expectedArtifacts) === + JSON.stringify([expectedArtifact]) && + delivery.structuredResult?.contractStatus === 'evidence-ready' && + Array.isArray(resultArtifacts) && + resultArtifacts.length > 0 && + resultArtifacts.some( + (artifact) => artifact.path === expectedArtifact, + ) && + resultArtifacts.every( + (artifact) => + isNonEmptyString(artifact.path) && + !path.isAbsolute(artifact.path) && + artifact.path.startsWith('assets/') && + !artifact.path.split('/').includes('..') && + /^[0-9a-f]{64}$/u.test(artifact.sha256), + ) && + delegateReceipt?.record.tool === 'agent.delegate' && + delegateReceipt.record.status === 'ok' && + assetListIndexes.some((index) => index < delegateReceipt.index) && + claims.length === 1 && + claim.schemaVersion === staticDelegateClaimSchemaVersion && + claim.parentAgentId === gameChatMainAgentId && + claim.parentRunId === codeTask.runId && + claim.status === 'observed' && + claimReceipt?.record.tool === 'agent.run_status' && + claimReceipt.record.status === 'ok' && + delivery.claimedByActionId === claim.actionId && + claimedReceipt?.targetAgentId === artTask.agentId && + claimedReceipt.status === delivery.terminalStatus, + 'supervisor-game-chat-single-main-art-delivery-invalid', + ); + artDeliveryByAgent.set(artTask.agentId, { + delivery, + delegateReceiptIndex: delegateReceipt.index, + claimReceiptIndex: claimReceipt.index, + }); + } + if ( + artDeliveryByAgent.has('art-director') && + artDeliveryByAgent.has('art-asset-plan') + ) { + assert( + artDeliveryByAgent.get('art-director').claimReceiptIndex >= 0 && + artDeliveryByAgent.get('art-director').claimReceiptIndex < + artDeliveryByAgent.get('art-asset-plan').delegateReceiptIndex, + 'supervisor-game-chat-single-main-art-order-invalid', + ); + } + return { + uniqueRootRunCount: rootTasks.length, + recursiveDescendantCount: reachableKeys.size - 1, + completedDescendantCount: reachableKeys.size - 1, + artChildCount: artTasks.length, + artDeliveryClaimCount: artDeliveryByAgent.size, + assetListBeforeArtDelegation: true, + }; +} + +export function supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision, + finalGameIndexSha256, +) { + return ( + gate?.lastVerificationStatus === 'passed' && + gate.verifiedRevision === revision && + gate.staticSmokeVerifiedRevision === revision && + gate.staticSmokeVerifiedGameIndexSha256 === finalGameIndexSha256 + ); +} + +export function supervisorGameChatFreshInitAcceptanceEvidence({ + freshInitBaselineUsed, + initialGameIndexSha256, + productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype, + finalGameIndexSha256, + gate, + revision, + viewportResults, +}) { + return { + freshInitBaselineUsed: freshInitBaselineUsed === true, + initialGameIndexMatchesProductionDefault: + /^[0-9a-f]{64}$/u.test(initialGameIndexSha256 ?? '') && + initialGameIndexSha256 === productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype: uniqueFixedChildCodePrototype === true, + finalGameIndexDiffersFromBaseline: + /^[0-9a-f]{64}$/u.test(finalGameIndexSha256 ?? '') && + finalGameIndexSha256 !== initialGameIndexSha256, + staticSmokeGameIndexSha256Matched: + supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision, + finalGameIndexSha256, + ), + desktopPlaytestPassed: + viewportResults?.find((entry) => entry.viewport === 'desktop')?.passed === + true, + mobilePlaytestPassed: + viewportResults?.find((entry) => entry.viewport === 'mobile')?.passed === + true, + }; +} + export async function validateSupervisorAutonomousPlayableEvidence( persistence, residualSidecars, ) { const task = supervisorAutonomousPlayableLaneDefenseTask; + const mode = supervisorAutonomousPlayableMode(); + const productionDefaultGameIndex = Buffer.from( + productionDefaultGameIndexHtml(), + 'utf8', + ); + const productionDefaultGameIndexSha256 = hashValue( + productionDefaultGameIndex, + ); + const initialGameIndexMatchesProductionDefault = + state.supervisorAutonomousPlayable.initialGameIndexSha256 === + productionDefaultGameIndexSha256; const report = state.supervisorAutonomousPlayable.turnReport; assert( report?.schemaVersion === 'game-creator-swarm-turn-report.v1' && @@ -814,7 +1313,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); assert( parentTask?.task === task && - parentTask.source === 'project-supervisor-cli' && + parentTask.source === mode.rootSource && parentTask.status === 'completed' && parentTask.phase === 'completed' && parentTask.runProfile === autonomousGameBuildRunProfile && @@ -861,6 +1360,12 @@ export async function validateSupervisorAutonomousPlayableEvidence( ...persistence.supervisorConversation, ...persistence.legacyConversation, ]; + const conversationBoundary = + inspectSupervisorAutonomousPlayableConversationBoundary( + persistence.legacyConversation, + userFacing, + state.initialRunId, + ); const allMessages = [ ...userFacing, ...professionalMessages, @@ -894,14 +1399,9 @@ export async function validateSupervisorAutonomousPlayableEvidence( supervisorSwarmSessionId, state.initialRunId, ) && - persistence.legacyConversation.filter((message) => - ['user', 'assistant'].includes(message.role), - ).length === 0 && - userFacing.filter( - (message) => - message.role === 'assistant' && - message.agentId !== projectSupervisorAgentId, - ).length === 0 && + conversationBoundary.legacyUserFacingMessageCount === 1 && + conversationBoundary.acceptedPublicStatusCount === 1 && + conversationBoundary.wrongAgentFinalAssistantCount === 0 && parentAssistantAudits.length === 1 && parentCompletedAudits.length === 1 && duplicateMessageCount === 0, @@ -996,7 +1496,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( binding.rootRunId === state.initialRunId && binding.parentAgentId == null && binding.parentRunId == null && - binding.source === 'project-supervisor-cli' && + binding.source === mode.rootSource && binding.profile === autonomousGameBuildRunProfile && binding.profileFingerprint === hashJsonValue({ @@ -1019,6 +1519,94 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); const contract = contracts[0]; const receipt = receipts[0]; + let evidenceBinding = binding; + let evidenceRunId = state.initialRunId; + let gameChatTopologyEvidence = {}; + let uniqueFixedChildCodePrototype = false; + if (mode.gameChatSingleMain) { + const manifest = JSON.parse( + decodeUtf8Fatal( + await fs.readFile(path.join(state.projectRoot, '.agent/manifest.json')), + 'supervisor-autonomous-playable-manifest-invalid-utf8', + ), + ); + const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : []; + assert( + tasks.length === 1 && + tasks[0]?.id === gameChatMainAgentId && + tasks[0]?.status === 'completed', + 'supervisor-game-chat-single-main-manifest-invalid', + ); + const rootChildTasks = persistence.taskSnapshot.latest.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const rootChildRuntimes = persistence.runtimeStates.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const codeTask = rootChildTasks[0]; + const codeRuntime = rootChildRuntimes[0]; + assert( + rootChildTasks.length === 1 && + codeTask.agentId === gameChatMainAgentId && + codeTask.status === 'completed' && + codeTask.phase === 'completed' && + codeTask.source === 'agent-ready-task-scheduler' && + rootChildRuntimes.length === 1 && + codeRuntime.agentId === gameChatMainAgentId && + codeRuntime.runId === codeTask.runId && + codeRuntime.phase === 'completed' && + codeRuntime.pendingToolAction == null && + codeRuntime.pendingAction == null, + 'supervisor-game-chat-single-main-runtime-invalid', + ); + evidenceRunId = codeTask.runId; + const codeBindings = runProfileBindings.filter( + (candidate) => + candidate.agentId === gameChatMainAgentId && + candidate.runId === evidenceRunId && + candidate.rootAgentId === projectSupervisorAgentId && + candidate.rootRunId === state.initialRunId && + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId && + candidate.profile === autonomousGameBuildRunProfile && + candidate.source === 'agent-ready-task-scheduler' && + candidate.parentBindingFingerprint === binding.bindingFingerprint, + ); + assert( + codeBindings.length === 1, + 'supervisor-game-chat-single-main-binding-invalid', + ); + uniqueFixedChildCodePrototype = true; + evidenceBinding = codeBindings[0]; + const [workflowDecisions, assetCoverages, assetRoutes] = await Promise.all([ + readSupervisorSwarmJsonDirectory( + '.agent/runtime/game-chat-workflow-decisions', + ), + readSupervisorSwarmJsonDirectory( + '.agent/runtime/game-chat-asset-coverage', + ), + readSupervisorSwarmJsonDirectory('.agent/runtime/game-chat-asset-routes'), + ]); + assert( + workflowDecisions.length === 1 && + assetCoverages.length === 1 && + assetRoutes.length === 1, + 'supervisor-game-chat-single-main-asset-sidecar-count-invalid', + ); + gameChatTopologyEvidence = validateSupervisorGameChatSingleMainTopology( + persistence, + runProfileBindings, + { + workflowDecision: workflowDecisions[0], + assetCoverage: assetCoverages[0], + assetRoute: assetRoutes[0], + }, + ); + } const contractIdentity = { schemaVersion: contract.schemaVersion, projectId: contract.projectId, @@ -1048,6 +1636,11 @@ export async function validateSupervisorAutonomousPlayableEvidence( state.supervisorAutonomousPlayable.initialGameIndexSha256 && Number.isInteger(contract.baselineArtifacts[0]?.sizeBytes) && contract.baselineArtifacts[0].sizeBytes > 0 && + (!mode.gameChatSingleMain || + (state.supervisorAutonomousPlayable.freshInitBaselineUsed === true && + initialGameIndexMatchesProductionDefault && + contract.baselineArtifacts[0].sizeBytes === + productionDefaultGameIndex.length)) && contract.playtestScenario === 'lane-defense-v1' && contract.contractFingerprint === hashJsonValue(contractIdentity), 'supervisor-autonomous-playable-completion-contract-invalid', @@ -1072,23 +1665,24 @@ export async function validateSupervisorAutonomousPlayableEvidence( await readSupervisorSwarmJsonDirectory('.agent/runtime/verification') ).filter( (gate) => - gate.agentId === projectSupervisorAgentId && - gate.runId === state.initialRunId, + gate.agentId === mode.evidenceAgentId && gate.runId === evidenceRunId, ); const gate = gates[0]; const staticSmokeAudits = persistence.agentDb.filter( (record) => record.recordType === 'agent.runtime.command.run_limited' && - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.commandId === 'game.static_smoke' && record.status === 'completed', ); assert( gates.length === 1 && - gate.lastVerificationTool === 'game.static_smoke' && - gate.lastVerificationStatus === 'passed' && - gate.verifiedRevision === revision.revision && + supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex( + gate, + revision.revision, + finalGameIndexSha256, + ) && staticSmokeAudits.length >= 1, 'supervisor-autonomous-playable-static-smoke-invalid', ); @@ -1112,9 +1706,10 @@ export async function validateSupervisorAutonomousPlayableEvidence( assert( receipt.schemaVersion === autonomousPlaytestReceiptSchemaVersion && receipt.projectId === contract.projectId && - receipt.agentId === projectSupervisorAgentId && - receipt.runId === state.initialRunId && - receipt.runProfileBindingFingerprint === binding.bindingFingerprint && + receipt.agentId === mode.evidenceAgentId && + receipt.runId === evidenceRunId && + receipt.runProfileBindingFingerprint === + evidenceBinding.bindingFingerprint && receipt.revision === revision.revision && receipt.gameIndex.path === 'game/index.html' && receipt.gameIndex.sha256 === finalGameIndexSha256 && @@ -1123,8 +1718,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( receipt.receiptFingerprint === hashJsonValue(receiptIdentity) && actionReceipts.filter( (record) => - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.actionId === receipt.actionId && record.actionFingerprint === receipt.actionFingerprint && record.tool === 'preview.validate' && @@ -1183,6 +1778,12 @@ export async function validateSupervisorAutonomousPlayableEvidence( const mobile = screenshotEntries.find(({ digest }) => digest.path.endsWith('/mobile.png'), ); + const desktopViewport = browserReport.viewportResults?.find( + (entry) => entry.viewport === 'desktop', + ); + const mobileViewport = browserReport.viewportResults?.find( + (entry) => entry.viewport === 'mobile', + ); const playtest = browserReport.playtest; const assertionNames = new Set( (playtest?.assertions ?? []).map((entry) => entry.name), @@ -1197,7 +1798,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( JSON.stringify( browserReport.viewportResults.map((entry) => entry.viewport).sort(), ) === JSON.stringify(['desktop', 'mobile']) && - browserReport.viewportResults.every((entry) => entry.passed === true) && + desktopViewport?.passed === true && + mobileViewport?.passed === true && playtest?.scenario === 'lane-defense-v1' && playtest.passed === true && playtest.scenarioFingerprint === receipt.scenarioFingerprint && @@ -1207,6 +1809,26 @@ export async function validateSupervisorAutonomousPlayableEvidence( ), 'supervisor-autonomous-playable-lane-defense-playtest-invalid', ); + const gameChatFreshInitAcceptance = + supervisorGameChatFreshInitAcceptanceEvidence({ + freshInitBaselineUsed: + state.supervisorAutonomousPlayable.freshInitBaselineUsed, + initialGameIndexSha256: + state.supervisorAutonomousPlayable.initialGameIndexSha256, + productionDefaultGameIndexSha256, + uniqueFixedChildCodePrototype, + finalGameIndexSha256, + gate, + revision: revision.revision, + viewportResults: browserReport.viewportResults, + }); + assert( + !mode.gameChatSingleMain || + Object.values(gameChatFreshInitAcceptance).every( + (value) => value === true, + ), + 'supervisor-game-chat-fresh-init-acceptance-evidence-invalid', + ); const lifecycle = persistence.agentDb.filter( (record) => @@ -1299,6 +1921,13 @@ export async function validateSupervisorAutonomousPlayableEvidence( const publicLeaks = collectSupervisorSwarmPublicLeakEvidence(persistence, { requireZero: true, }); + const projectLureLeakCount = await countLureLeaks(); + const cliLureLeakCount = countExactSecrets( + Buffer.from(state.supervisorAutonomousPlayable.cliOutput), + state.lures, + ); + state.lureLeakCount = projectLureLeakCount + cliLureLeakCount; + assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); state.supervisorAutonomousPlayable.privateValues = [ ...new Set(state.supervisorSwarm.privateValues), ]; @@ -1352,6 +1981,22 @@ export async function validateSupervisorAutonomousPlayableEvidence( turnReport: report, }, ); + const providerBinding = + state.supervisorAutonomousPlayable.effectiveProviderBinding; + assert( + providerBinding && + JSON.stringify(providerBinding) === + JSON.stringify( + state.supervisorAutonomousPlayable.expectedProviderBinding, + ) && + (!isSupervisorGameChatSingleMainPlayableSuite() || + providerBinding.providerAgentMode === 'provider') && + isNonEmptyString(providerBinding.providerModel) && + isNonEmptyString(providerBinding.providerApiKind) && + isNonEmptyString(providerBinding.providerReasoningEffort) && + /^[0-9a-f]{64}$/u.test(providerBinding.providerBaseUrlSha256), + 'supervisor-autonomous-playable-provider-binding-invalid', + ); return { ...emptySupervisorAutonomousPlayableEvidence(), @@ -1364,6 +2009,14 @@ export async function validateSupervisorAutonomousPlayableEvidence( stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, taskSha256: hashValue(task), + providerAgentMode: providerBinding.providerAgentMode, + providerModel: providerBinding.providerModel, + providerApiKind: providerBinding.providerApiKind, + providerReasoningEffort: providerBinding.providerReasoningEffort, + providerBaseUrlSha256: providerBinding.providerBaseUrlSha256, + providerBoundAgentCount: providerBinding.boundAgentIds.length, + providerBindingMatched: true, + ...gameChatTopologyEvidence, turnReportCaptured: true, turnReportParentIdentityStable: true, turnReportNewAssistantMessageCount: report.newAssistantMessageCount, @@ -1434,6 +2087,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( baselineRevision: contract.baselineRevision, projectRevision: revision.revision, projectRevisionDelta: revision.revision - contract.baselineRevision, + ...gameChatFreshInitAcceptance, initialGameIndexSha256: state.supervisorAutonomousPlayable.initialGameIndexSha256, finalGameIndexSha256, @@ -1492,12 +2146,37 @@ export async function validateSupervisorAutonomousPlayableEvidence( export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { await ensureOwnedRunnerStableKillSupport(); - await seedDisposableProject(); - const initialGameIndex = await fs.readFile( - path.join(state.projectRoot, 'game/index.html'), - ); - state.supervisorAutonomousPlayable.initialGameIndexSha256 = - hashValue(initialGameIndex); + const mode = supervisorAutonomousPlayableMode(); + state.supervisorAutonomousPlayable.freshInitBaselineUsed = + mode.gameChatSingleMain; + await seedDisposableProject({ + preserveProductionInitBaseline: mode.gameChatSingleMain, + }); + if (mode.gameChatSingleMain) { + const generatedBaselinePaths = [ + 'package.json', + 'verify-e2e.mjs', + 'game/index.html', + ]; + const generatedBaselineMetadata = await Promise.all( + generatedBaselinePaths.map((relativePath) => + fs.lstat(path.join(state.projectRoot, relativePath)).catch(() => null), + ), + ); + assert( + generatedBaselineMetadata.every((metadata) => metadata === null), + 'supervisor-game-chat-fresh-init-project-not-empty', + ); + state.supervisorAutonomousPlayable.initialGameIndexSha256 = hashValue( + Buffer.from(productionDefaultGameIndexHtml(), 'utf8'), + ); + } else { + const initialGameIndex = await fs.readFile( + path.join(state.projectRoot, 'game/index.html'), + ); + state.supervisorAutonomousPlayable.initialGameIndexSha256 = + hashValue(initialGameIndex); + } state.cliBinary = await prepareCliBinary(); await prepareIsolatedSuiteAppData(); state.isolatedRunner.launchAttempted = true; @@ -1515,6 +2194,7 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { '--swarm-chat', '--init', '--autonomous-game-build', + ...mode.cliFlags, state.projectRoot, ]); state.supervisorAutonomousPlayable.stdinWriteCount = 1; @@ -1529,7 +2209,6 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { const report = await waitForSupervisorAutonomousPlayableCliExit( state.supervisorAutonomousPlayableCliSession, ); - state.supervisorAutonomousPlayableCliSession = null; assert( report.outcome === 'settled', 'supervisor-autonomous-playable-turn-not-settled', @@ -1546,8 +2225,9 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { } export function emptySupervisorAutonomousPlayableEvidence() { + const mode = supervisorAutonomousPlayableMode(); return { - scenario: 'project-supervisor-autonomous-playable-lane-defense', + scenario: mode.scenario, targetAgentId: projectSupervisorAgentId, runProfile: autonomousGameBuildRunProfile, dedicatedZeroInterventionPath: true, @@ -1560,6 +2240,13 @@ export function emptySupervisorAutonomousPlayableEvidence() { stdinEndedAfterTask: false, stdinBytes: 0, taskSha256: null, + providerAgentMode: null, + providerModel: null, + providerApiKind: null, + providerReasoningEffort: null, + providerBaseUrlSha256: null, + providerBoundAgentCount: 0, + providerBindingMatched: false, evidenceCompleteness: 'none', partialEvidenceCollected: false, partialEvidenceReadErrorCount: 0, @@ -1605,6 +2292,12 @@ export function emptySupervisorAutonomousPlayableEvidence() { childTaskCount: 0, runtimeCount: 0, childRuntimeCount: 0, + uniqueRootRunCount: 0, + recursiveDescendantCount: 0, + completedDescendantCount: 0, + artChildCount: 0, + artDeliveryClaimCount: 0, + assetListBeforeArtDelegation: false, finalSupervisorAssistantCount: 0, supervisorUserMessageCount: 0, professionalAssistantCount: 0, @@ -1635,11 +2328,16 @@ export function emptySupervisorAutonomousPlayableEvidence() { baselineRevision: 0, projectRevision: 0, projectRevisionDelta: 0, + freshInitBaselineUsed: false, + initialGameIndexMatchesProductionDefault: false, initialGameIndexSha256: null, finalGameIndexSha256: null, gameIndexChanged: false, + finalGameIndexDiffersFromBaseline: false, + uniqueFixedChildCodePrototype: false, gameIndexBytes: 0, staticSmokePassed: false, + staticSmokeGameIndexSha256Matched: false, staticSmokeAuditCount: 0, autonomousCompletionContractCount: 0, autonomousPlaytestReceiptCount: 0, @@ -1648,6 +2346,8 @@ export function emptySupervisorAutonomousPlayableEvidence() { laneDefenseAssertionCount: 0, laneDefensePassedAssertionCount: 0, browserValidationPassed: false, + desktopPlaytestPassed: false, + mobilePlaytestPassed: false, desktopScreenshotSha256: null, desktopScreenshotBytes: 0, mobileScreenshotSha256: null, @@ -1681,6 +2381,20 @@ export function emptySupervisorAutonomousPlayableEvidence() { supervisorAutonomousPlayableReportLeakCount: 0, supervisorAutonomousPlayableRunnerStopped: false, supervisorAutonomousPlayableAppDataCleanupPerformed: false, + ownedProcessIdentityCaptured: false, + ownedRunnerObservedCount: 0, + ownedHelperObservedCount: 0, + ownedNodeDescendantObservedCount: 0, + ownedBrowserDescendantObservedCount: 0, + ownedCommandDescendantObservedCount: 0, + ownedRunnerResidualCount: 0, + ownedHelperResidualCount: 0, + ownedNodeDescendantResidualCount: 0, + ownedBrowserDescendantResidualCount: 0, + ownedCommandDescendantResidualCount: 0, + activeCommandChildrenAfterCleanup: 0, + activeInteractiveCliSessionsAfterCleanup: 0, + ownedProcessCleanupPassed: false, secretLeakCount: 0, lureLeakCount: 0, paths: [], @@ -1688,5 +2402,12 @@ export function emptySupervisorAutonomousPlayableEvidence() { } export function isSupervisorAutonomousPlayableLaneDefenseSuite() { - return state.suite === supervisorAutonomousPlayableLaneDefenseSuite; + return ( + state.suite === supervisorAutonomousPlayableLaneDefenseSuite || + state.suite === supervisorGameChatSingleMainPlayableSuite + ); +} + +export function isSupervisorGameChatSingleMainPlayableSuite() { + return state.suite === supervisorGameChatSingleMainPlayableSuite; } diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs index 877a8aa70..278c7acf3 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs @@ -90,6 +90,37 @@ export async function listOptionalSupervisorSwarmFile(file) { return [file]; } +export function supervisorSwarmProfessionalSessions({ + deliveries = [], + latestTasks = [], + rootAgentId, + rootRunId, +}) { + const sessions = new Map(); + const registerSession = (agentId, sessionId) => { + if (!isNonEmptyString(agentId) || !isNonEmptyString(sessionId)) return; + sessions.set(`${agentId}\0${sessionId}`, { agentId, sessionId }); + }; + for (const delivery of deliveries) { + registerSession(delivery.targetAgentId, delivery.targetSessionId); + } + if (!isNonEmptyString(rootAgentId) || !isNonEmptyString(rootRunId)) { + return [...sessions.values()]; + } + for (const task of latestTasks) { + if ( + task.source !== 'agent-ready-task-scheduler' || + task.agentId === rootAgentId || + task.parentAgentId !== rootAgentId || + task.parentRunId !== rootRunId + ) { + continue; + } + registerSession(task.agentId, task.sessionId); + } + return [...sessions.values()]; +} + export async function readSupervisorSwarmPersistence({ tolerateErrors = false, } = {}) { @@ -274,25 +305,15 @@ export async function readSupervisorSwarmPersistence({ ), ); const supervisorConversation = supervisorConversationSurface.records; - const professionalSessions = new Map(); - for (const delivery of deliveries) { - if ( - !isNonEmptyString(delivery.targetAgentId) || - !isNonEmptyString(delivery.targetSessionId) - ) { - continue; - } - professionalSessions.set( - `${delivery.targetAgentId}\0${delivery.targetSessionId}`, - { - agentId: delivery.targetAgentId, - sessionId: delivery.targetSessionId, - }, - ); - } + const professionalSessions = supervisorSwarmProfessionalSessions({ + deliveries, + latestTasks: taskSnapshot.latest, + rootAgentId: projectSupervisorAgentId, + rootRunId: state.initialRunId, + }); const professionalConversations = []; const professionalConversationErrors = []; - for (const session of professionalSessions.values()) { + for (const session of professionalSessions) { const conversationSurface = await readJsonlSurface( 'professional-conversation', () => diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index d2cead3a5..c624b7aa8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -312,7 +312,9 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) { function parseAppInvokeCommandNames(source) { return Array.from( - source.matchAll(/invoke(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g), + source.matchAll( + /(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g, + ), ([, command]) => command, ); } @@ -1164,6 +1166,7 @@ if ( !Array.isArray(tauriConfig.app?.windows) || tauriConfig.app.windows.length !== 1 || tauriConfig.app.windows[0]?.label !== 'client' || + tauriConfig.app.windows[0]?.title !== '陶泥儿' || tauriConfig.app.windows[0]?.url !== 'index.html' ) { throw new Error( @@ -1186,11 +1189,12 @@ const allowedLlmReasoningEfforts = new Set([ 'low', 'medium', 'high', + 'max', ]); -if (defaultAppConfig.llm?.reasoningEffort !== 'high') { +if (defaultAppConfig.llm?.reasoningEffort !== 'max') { throw new Error( - 'AI game creator shell default llm.reasoningEffort must stay high', + 'AI game creator shell default llm.reasoningEffort must stay max', ); } @@ -1207,9 +1211,9 @@ for (const [agentId, agentConfig] of Object.entries( } } -if (defaultAppConfig.editorApi?.apiKey !== '') { +if (defaultAppConfig.editorApi !== undefined) { throw new Error( - 'AI game creator shell default editorApi.apiKey must stay empty', + 'AI game creator shell ordinary default config must not contain editorApi', ); } @@ -1539,7 +1543,7 @@ if ( for (const snippet of [ 'resolveAgcDevEndpoint', 'withAgcDevEndpointEnv', - "response.body.includes('AI 游戏创作')", + "response.body.includes('陶泥儿')", 'function isPortListening()', 'cannot be safely reused', 'non-HTTP or unrecognized server', @@ -1666,10 +1670,6 @@ if ( for (const snippet of [ 'import.meta.env.DEV', - '#[cfg(all(debug_assertions, not(test)))]', - 'open_developer_window(app.handle())?', - 'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())', - 'index.html?agent-chat', 'supervisorChatMode', 'supervisorChatOnly', 'open_project_supervisor_chat_window', @@ -1682,6 +1682,12 @@ for (const snippet of [ } } +if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) { + throw new Error( + 'AI game creator normal startup must not automatically open the developer window', + ); +} + const smokeAgentRunSource = fs.readFileSync( new URL('./smoke-agent-run-local-provider.mjs', import.meta.url), 'utf8', diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 7cd45583a..4289684ec 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -43,7 +43,6 @@ export const deterministicManifestReadyAgentIds = Object.freeze([ const deterministicReadOnlyReadyAgentIds = new Set([ 'design-director', 'balance-director', - 'art-director', 'art-polish', 'audio-director', 'code-director', @@ -82,7 +81,11 @@ function pngChunk(type, data) { return Buffer.concat([length, typeBytes, data, crc]); } -function deterministicPng(width, height) { +function deterministicPng( + width, + height, + { variant = 0, transparent = false } = {}, +) { const stride = width * 4 + 1; const pixels = Buffer.alloc(stride * height); for (let y = 0; y < height; y += 1) { @@ -91,10 +94,11 @@ function deterministicPng(width, height) { for (let x = 0; x < width; x += 1) { const offset = row + 1 + x * 4; const tile = (Math.floor(x / 48) + Math.floor(y / 48)) % 6; - pixels[offset] = (42 + x + tile * 29) % 256; - pixels[offset + 1] = (86 + y * 2 + tile * 17) % 256; - pixels[offset + 2] = (118 + x + y + tile * 31) % 256; - pixels[offset + 3] = 255; + pixels[offset] = (42 + x + tile * 29 + variant * 13) % 256; + pixels[offset + 1] = (86 + y * 2 + tile * 17 + variant * 19) % 256; + pixels[offset + 2] = (118 + x + y + tile * 31 + variant * 23) % 256; + pixels[offset + 3] = + transparent && (x + y + variant) % 11 === 0 ? 0 : 255; } } const ihdr = Buffer.alloc(13); @@ -120,7 +124,7 @@ export function deterministicLaneDefenseInitialHtml() { *{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}} -
Garden defenders +
Garden defenders

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

@@ -128,6 +132,7 @@ export function deterministicLaneDefenseInitialHtml() {
`; } @@ -374,12 +379,12 @@ function isUiPrototypeInspectionRequest(payload, context) { : false; return ( hasInputImage && - context.includes('resourceBar') && - context.includes('unitCardTray') && - context.includes('battlefieldGrid') && - context.includes('enemyEntryDirection') && - context.includes('waveStatus') && + context.includes('informationHud') && + context.includes('gameplaySurface') && + context.includes('objectiveEntities') && context.includes('primaryControls') && + context.includes('failureRestartFlow') && + context.includes('responsiveLayout') && context.includes('implementationClarity') && context.includes('originalTheme') ); @@ -397,6 +402,152 @@ function nativeResponse(response) { return { name: 'respond_to_user', arguments: { response } }; } +function goalContractCall() { + return nativeAction( + 'agent.goal_contract', + '冻结本轮可玩游戏交付目标与验收标准', + { + outcome: + '交付一个可完整游玩的原创植物塔防网页游戏,并让当前最终 revision 通过自动静态检查和桌面、移动双视口真实试玩。', + nonNegotiables: [ + '必须形成从开始、放置防御单位、敌人移动与受伤到胜利、下一关和重开的完整闭环', + '必须在无人确认、无人补充输入和无人 steer 的条件下完成', + ], + preferences: ['保持单页自包含并提供清晰的桌面与移动端操作反馈'], + forbiddenAssumptions: [ + '不得把静态页面、占位素材或未执行的试玩声明为完成', + ], + openQuestions: [], + acceptanceNodes: [ + { + criterionId: 'static-current-revision', + criterion: + '当前最终 revision 的游戏入口通过可执行 JavaScript、Canvas 与完整玩法合同静态检查', + required: true, + requiredEvidence: ['tool:command.run_limited'], + dependsOn: [], + }, + { + criterionId: 'playable-current-revision', + criterion: + '当前最终 revision 的完整塔防循环在桌面与移动视口均通过真实浏览器试玩', + required: true, + requiredEvidence: ['tool:preview.validate'], + dependsOn: ['static-current-revision'], + }, + ], + }, + ); +} + +function actionHistoryCall() { + return nativeAction( + 'agent.action_history', + '读取当前根 Run 的最终静态检查与真实试玩动作回执', + { + runId: null, + actionId: null, + tool: null, + status: 'ok', + limit: 10, + }, + ); +} + +function goalContractFingerprint(context) { + const marker = '[Root Goal Contract]'; + const start = context.lastIndexOf(marker); + if (start < 0) return null; + const tail = context.slice(start); + const end = tail.indexOf('\n\n'); + const goalContractContext = end < 0 ? tail : tail.slice(0, end); + const matches = new Set( + [ + ...goalContractContext.matchAll( + /"contractFingerprint"\s*:\s*"([0-9a-f]{64})"/giu, + ), + ].map((match) => match[1].toLowerCase()), + ); + return matches.size === 1 ? [...matches][0] : null; +} + +function finalAcceptanceEvidence(context, expectedRunId) { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'agent.action_history' || + observation?.status !== 'ok' || + typeof observation.detail !== 'string' + ) { + throw providerError('provider-final-action-history-observation-invalid'); + } + let detail; + try { + detail = JSON.parse(observation.detail); + } catch { + throw providerError('provider-final-action-history-detail-invalid'); + } + if ( + detail?.runId !== expectedRunId || + !Array.isArray(detail?.actions) || + detail?.count !== detail.actions.length + ) { + throw providerError('provider-final-action-history-identity-invalid'); + } + const evidenceByTool = new Map(); + for (const action of detail.actions) { + if ( + action?.agentId !== 'project-supervisor' || + action?.runId !== expectedRunId || + action?.status !== 'ok' || + typeof action?.actionId !== 'string' || + !/^action-[0-9a-f]{24}$/iu.test(action.actionId) || + !['command.run_limited', 'preview.validate'].includes(action?.tool) + ) { + continue; + } + evidenceByTool.set(action.tool, { + agentId: action.agentId, + runId: action.runId, + actionId: action.actionId, + }); + } + const staticSmoke = evidenceByTool.get('command.run_limited'); + const preview = evidenceByTool.get('preview.validate'); + if (!staticSmoke || !preview) { + throw providerError('provider-final-acceptance-evidence-missing'); + } + return { staticSmoke, preview }; +} + +function acceptanceUpdateCall(context, runId) { + const contractFingerprint = goalContractFingerprint(context); + if (!contractFingerprint) { + throw providerError('provider-goal-contract-fingerprint-missing'); + } + const evidence = finalAcceptanceEvidence(context, runId); + return nativeAction( + 'agent.acceptance_update', + '提交当前最终 revision 的静态检查与双视口试玩验收证据', + { + contractFingerprint, + evaluations: [ + { + criterionId: 'static-current-revision', + status: 'passed', + evidence: [evidence.staticSmoke], + summary: '当前最终 revision 已通过 game.static_smoke。', + }, + { + criterionId: 'playable-current-revision', + status: 'passed', + evidence: [evidence.preview], + summary: '当前最终 revision 已通过桌面与移动双视口真实试玩。', + }, + ], + }, + ); +} + function completedPlanCall(explanation, steps) { return { name: 'update_agent_plan', @@ -463,17 +614,6 @@ function chatTextResponse(sequence, model, content) { }; } -function delegateCall(agentId, task, acceptanceCriteria, expectedArtifacts) { - return nativeAction('agent.delegate', `把独立工作交给 ${agentId}`, { - agentId, - task, - acceptanceCriteria, - expectedArtifacts, - repairOfDelegationId: null, - runId: null, - }); -} - const projectMutationFunctionNames = new Set( [ 'blackboard.write', @@ -492,22 +632,6 @@ const projectMutationFunctionNames = new Set( ].map(runtimeFunction), ); -function isGameIndexArtifactContract(expectedArtifacts) { - return ( - Array.isArray(expectedArtifacts) && - expectedArtifacts.length === 1 && - expectedArtifacts[0] === 'game/index.html' - ); -} - -function isExplicitReadOnlyQualityTask(task) { - return ( - typeof task === 'string' && - task.includes('只读验收') && - task.includes('不要修改任何文件') - ); -} - function staticSmokeCall(reason) { return nativeAction('command.run_limited', reason, { commandId: 'game.static_smoke', @@ -540,15 +664,6 @@ function runStatusCall(reason) { }); } -function repairPatchCall(reason) { - return nativeAction('file.patch', reason, { - path: 'game/index.html', - oldText: hiddenCanvasCss, - newText: visibleCanvasCss, - expectedReplacements: 1, - }); -} - function fileReadCall(path, reason) { return nativeAction('file.read', reason, { path, @@ -566,6 +681,18 @@ function assetListCall(reason) { } function canvasAssetCall(agentId) { + if (agentId === 'art-director') { + return nativeAction('canvas.asset_generate', '生成统一视觉规范图', { + prompt: + '原创明亮花园植物塔防游戏的方形图标规范图。统一展示露华花守卫、棘刺芽守卫、雾影兽入侵者、晶苔地块、灵露资源和主要 UI 图标的轮廓、配色、材质与尺寸关系;纯色分区背景,适合后续角色、场景和 UI 共用,不使用任何现有游戏角色、Logo 或受保护视觉语言。', + outputPath: 'assets/art-spec.png', + aspectRatio: '1:1', + imageSize: '1K', + assetKind: 'icon-spec', + assetLabel: '游戏统一视觉规范图', + replaceExisting: false, + }); + } if (agentId === 'design-foundation') { return nativeAction('canvas.asset_generate', '生成横屏游戏界面原型', { prompt: @@ -635,6 +762,67 @@ function revisionBlockedObservation(context) { ); } +function projectRevisionDriftObservation(context, tool) { + const observation = latestToolObservation(context); + const detail = + typeof observation?.detail === 'string' ? observation.detail : ''; + const hasStructuredDetail = + detail.includes('projectRevisionDrift=true') && + detail.includes('expectedRevision=') && + detail.includes('currentRevision=') && + detail.includes('replanRequired=true'); + const detailUnavailable = + observation?.detail == null || detail === '[redacted sensitive context]'; + return ( + observation?.tool === tool && + observation?.status === 'blocked' && + observation?.summary === '并行项目变更使旧动作过期,旧动作未执行' && + (hasStructuredDetail || detailUnavailable) + ); +} + +function visualAssetBlockedObservation(context) { + const observation = latestToolObservation(context); + return ( + observation?.tool === 'runtime.visual_asset' && + observation?.status === 'blocked' && + typeof observation?.detail === 'string' && + observation.detail.includes('expectedPath=assets/ui-prototype.png') && + observation.detail.includes('requiredInspection=image.inspect') + ); +} + +function missingGeneratedVisualAssetObservation(context, agentId) { + const expected = { + 'art-director': { + path: 'assets/art-spec.png', + kind: 'icon-spec', + summary: '统一视觉规范图尚未按正式视觉流程生成并登记,不能完成任务', + }, + 'design-foundation': { + path: 'assets/ui-prototype.png', + kind: 'ui-prototype', + summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', + }, + 'art-asset-plan': { + path: 'assets/art-spritesheet.png', + kind: 'art-spritesheet', + summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', + }, + }[agentId]; + if (!expected) return false; + const observation = latestToolObservation(context); + return ( + observation?.tool === 'runtime.visual_asset' && + observation?.status === 'blocked' && + observation?.summary === expected.summary && + typeof observation?.detail === 'string' && + (observation.detail === '[redacted sensitive context]' || + (observation.detail.includes(`expectedPath=${expected.path}`) && + observation.detail.includes(`expectedKind=${expected.kind}`))) + ); +} + function specialistVerificationRepairContext(context) { return ( context.includes( @@ -646,17 +834,27 @@ function specialistVerificationRepairContext(context) { ); } +function verifiedDeliveryRepairContext(context) { + return ( + context.includes('当前 revision 已通过验证') && + context.includes('本次修复的原生工具目录只保留 respond_to_user') + ); +} + function transientCommandObservation(context) { const observation = latestToolObservation(context); - const detail = observation?.detail; + const detail = + typeof observation?.detail === 'string' + ? observation.detail.replaceAll('\\', '/') + : ''; return ( observation?.tool === 'command.run_limited' && ((observation?.status === 'blocked' && - (detail?.includes('repositoryContextDrift=true') || - (detail?.includes('projectRevisionDrift=true') && + (detail.includes('repositoryContextDrift=true') || + (detail.includes('projectRevisionDrift=true') && detail.includes('replanRequired=true')))) || (observation?.status === 'failed' && - detail?.includes('.agent/project.lock'))) + detail.includes('.agent/project.lock'))) ); } @@ -668,13 +866,6 @@ function successfulProjectMutationObservation(context) { ); } -function successfulCommandObservation(context) { - const observation = latestToolObservation(context); - return ( - observation?.tool === 'command.run_limited' && observation?.status === 'ok' - ); -} - function observationsIncludeAsset(context, path) { const observations = observationContext(context); return observations.includes('# 本地项目资产') && observations.includes(path); @@ -722,36 +913,20 @@ export function createDeterministicLaneDefenseRouter({ const readyTaskCompletionRetryCounts = new Map(); const readyTaskPreCompletionRetryCounts = new Map(); const readyTaskReplayAvailableRuns = new Set(); - const terminalRetryCounts = new Map(); - const terminalVerificationPendingRuns = new Set(); - const terminalReplayAvailableRuns = new Set(); - const terminalObservationFingerprints = new Map(); const agentCounts = new Map(); const stats = { requestCount: 0, contextCompactionRequestCount: 0, planningRequestCount: 0, finalReplyRequestCount: 0, - initialDelegationCount: 0, - followupDelegationCount: 0, + goalContractCount: 0, + actionHistoryCount: 0, + acceptanceUpdateCount: 0, + delegateActionCount: 0, runStatusCount: 0, sourceWriteCount: 0, - sourcePatchCount: 0, staticSmokeCount: 0, previewValidationCount: 0, - supervisorDirectMutationAttemptCount: 0, - delegationContractCount: 0, - delegationContractViolationCount: 0, - codePrototypeDelegationCount: 0, - codePrototypeExpectedArtifactCount: 0, - codePrototypeGameIndexArtifactDelegationCount: 0, - qualityReviewDelegationCount: 0, - qualityReviewReadOnlyDelegationCount: 0, - qualityReviewExpectedArtifactCount: 0, - artAssetPlanDelegationCount: 0, - artAssetPlanExpectedArtifactCount: 0, - artAssetPlanArtifactContractCount: 0, - qualityRevisionReplanCount: 0, manifestReadyTaskRunCount: 0, manifestReadyTaskCompletionCount: 0, manifestReadyTaskFileReadCount: 0, @@ -760,6 +935,7 @@ export function createDeterministicLaneDefenseRouter({ manifestReadyTaskPreviewValidationCount: 0, manifestReadyTaskCanvasGenerationCount: 0, canonicalCodeRunCount: 0, + canonicalCodeAssetListCount: 0, imageInspectionRequestCount: 0, interactionExecuteCount: 0, readyTaskRunsByAgent: {}, @@ -771,9 +947,7 @@ export function createDeterministicLaneDefenseRouter({ byAgent: {}, }; let responseSequence = 0; - let parentStage = 'initial-delegation'; - let initialBuilderRunId = null; - let repairBuilderRunId = null; + let parentStage = 'goal-contract'; function updateReadyTaskCounts(agentId, kind) { const current = stats.readyTaskCountsByAgent[agentId] ?? { @@ -784,53 +958,6 @@ export function createDeterministicLaneDefenseRouter({ stats.readyTaskCountsByAgent[agentId] = current; } - function consumeTerminalRetry(agentId, runId) { - const retryCount = terminalRetryCounts.get(runId) ?? 0; - if (retryCount >= 16) { - throw providerError(`provider-terminal-retry-exhausted:${agentId}`); - } - terminalRetryCounts.set(runId, retryCount + 1); - } - - function retryTerminalStaticVerification(agentId, runId, tools, reason) { - if (!tools.has(runtimeFunction('command.run_limited'))) { - throw providerError( - `provider-terminal-retry-verification-tool-missing:${agentId}`, - ); - } - consumeTerminalRetry(agentId, runId); - terminalReplayAvailableRuns.delete(runId); - terminalVerificationPendingRuns.add(runId); - stats.staticSmokeCount += 1; - return callsResponse(agentId, tools, [staticSmokeCall(reason)]); - } - - function consumeTerminalReplayAuthorization(agentId, runId) { - if (terminalVerificationPendingRuns.delete(runId)) return; - if (terminalReplayAvailableRuns.delete(runId)) return; - throw providerError(`provider-terminal-replay-unarmed:${agentId}`); - } - - function consumeUniqueTerminalObservation(agentId, runId, context) { - const observation = latestToolObservation(context); - const repairFingerprint = context.includes('当前专业合同明确要求只读交付') - ? 'read-only-delivery-repair' - : null; - if (!observation && !repairFingerprint) { - throw providerError(`provider-terminal-observation-missing:${agentId}`); - } - const fingerprint = observation - ? JSON.stringify(observation) - : repairFingerprint; - const seen = terminalObservationFingerprints.get(runId) ?? new Set(); - if (seen.has(fingerprint)) { - throw providerError(`provider-terminal-observation-duplicate:${agentId}`); - } - consumeTerminalRetry(agentId, runId); - seen.add(fingerprint); - terminalObservationFingerprints.set(runId, seen); - } - function recordReadyTaskRun(agentId, runId) { if (!deterministicManifestReadyAgentIds.includes(agentId)) { throw providerError(`provider-ready-agent-unsupported:${agentId}`); @@ -893,6 +1020,21 @@ export function createDeterministicLaneDefenseRouter({ (agentId === 'preview-playtest' ? 'preview.validate' : 'command.run_limited'); + if ( + tools.size === 1 && + tools.has('respond_to_user') && + verifiedDeliveryRepairContext(context) + ) { + readyTaskCompletionRetryArmedRuns.delete(runId); + readyTaskCompletionVerificationTools.delete(runId); + readyTaskReplayAvailableRuns.delete(runId); + return readyCallsResponse( + agentId, + runId, + tools, + readyTaskFinalizationCalls(context, agentId, tools), + ); + } if (readyTaskCompletionRetryArmedRuns.has(runId)) { const observation = latestToolObservation(context); if ( @@ -914,6 +1056,22 @@ export function createDeterministicLaneDefenseRouter({ readyTaskFinalizationCalls(context, agentId, tools), ); } + if ( + expectedVerificationTool === 'image.inspect' && + observation?.tool === 'image.inspect' && + ['blocked', 'failed'].includes(observation?.status) && + tools.has(runtimeFunction('image.inspect')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + return readyCallsResponse(agentId, runId, tools, [ + imageInspectCall(), + ]); + } const transientCommandFailure = transientCommandObservation(context); if ( transientCommandFailure && @@ -936,6 +1094,36 @@ export function createDeterministicLaneDefenseRouter({ ); } const latestObservation = latestToolObservation(context); + if ( + missingGeneratedVisualAssetObservation(context, agentId) && + tools.has(runtimeFunction('canvas.asset_generate')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if ( + agentId === 'design-foundation' && + visualAssetBlockedObservation(context) && + tools.has(runtimeFunction('image.inspect')) + ) { + if (retryCount >= 16) { + throw providerError( + `provider-ready-retry-verification-exhausted:${agentId}`, + ); + } + readyTaskCompletionRetryCounts.set(runId, retryCount + 1); + readyTaskCompletionRetryArmedRuns.add(runId); + readyTaskCompletionVerificationTools.set(runId, 'image.inspect'); + return readyCallsResponse(agentId, runId, tools, [imageInspectCall()]); + } const successfulProjectMutation = successfulProjectMutationObservation(context); const standaloneSuccessfulVerification = @@ -1120,52 +1308,20 @@ export function createDeterministicLaneDefenseRouter({ publishAgentCounts(); for (const call of calls) { - if (call.name !== runtimeFunction('agent.delegate')) continue; - stats.delegationContractCount += 1; - const input = call.arguments?.input; - const hasExplicitExpectedArtifacts = Array.isArray( - input?.expectedArtifacts, - ); - const expectedArtifacts = hasExplicitExpectedArtifacts - ? input.expectedArtifacts - : []; - if (input?.agentId === 'code-prototype') { - stats.codePrototypeDelegationCount += 1; - stats.codePrototypeExpectedArtifactCount += expectedArtifacts.length; - if (isGameIndexArtifactContract(expectedArtifacts)) { - stats.codePrototypeGameIndexArtifactDelegationCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else if (input?.agentId === 'quality-review') { - stats.qualityReviewDelegationCount += 1; - stats.qualityReviewExpectedArtifactCount += expectedArtifacts.length; - if ( - hasExplicitExpectedArtifacts && - expectedArtifacts.length === 0 && - isExplicitReadOnlyQualityTask(input.task) - ) { - stats.qualityReviewReadOnlyDelegationCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else if (input?.agentId === 'art-asset-plan') { - stats.artAssetPlanDelegationCount += 1; - stats.artAssetPlanExpectedArtifactCount += expectedArtifacts.length; - if ( - hasExplicitExpectedArtifacts && - expectedArtifacts.length === 2 && - expectedArtifacts.includes('assets/manifest.art.json') && - expectedArtifacts.includes('assets/art-spritesheet.png') && - typeof input?.task === 'string' && - input.task.includes('生成') - ) { - stats.artAssetPlanArtifactContractCount += 1; - } else { - stats.delegationContractViolationCount += 1; - } - } else { - stats.delegationContractViolationCount += 1; + if (call.name === runtimeFunction('agent.goal_contract')) { + stats.goalContractCount += 1; + } else if (call.name === runtimeFunction('agent.action_history')) { + stats.actionHistoryCount += 1; + } else if (call.name === runtimeFunction('agent.acceptance_update')) { + stats.acceptanceUpdateCount += 1; + } else if (call.name === runtimeFunction('agent.delegate')) { + stats.delegateActionCount += 1; + } + if ( + agentId === 'code-prototype' && + call.name === runtimeFunction('asset.list') + ) { + stats.canonicalCodeAssetListCount += 1; } } } @@ -1178,177 +1334,134 @@ export function createDeterministicLaneDefenseRouter({ } function parentCalls(context, tools) { - const manifestDagWaitPrompt = - context.includes('当前正式 manifest DAG 仍有专业 task 在运行') && - tools.has(runtimeFunction('task.list')) && - tools.has(runtimeFunction('agent.run_status')); - if (manifestDagWaitPrompt) { - parentStage = 'await-manifest'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}), - runStatusCall('等待并读取并行专业任务的最新状态'), - ]); - } - const delegateRepairPrompt = - (context.includes('当前父 run 已进入只编排模式') && - context.includes('只保留 agent.delegate')) || - (parentStage === 'await-delegation-repair' && - tools.has(runtimeFunction('agent.delegate'))); - if (delegateRepairPrompt) { - if (parentStage !== 'await-delegation-repair') { - throw providerError('provider-repair-prompt-out-of-order'); + const goalContractTool = runtimeFunction('agent.goal_contract'); + if (tools.size === 1 && tools.has(goalContractTool)) { + if (parentStage !== 'goal-contract') { + throw providerError('provider-goal-contract-out-of-order'); } - parentStage = 'verify-repair'; - stats.followupDelegationCount += 1; - return callsResponse('project-supervisor', tools, [ - delegateCall( - 'code-prototype', - '修复最近一次真实浏览器验证证明的 canvas 不可见问题;保持现有 lane-defense-v1 交互、状态面、关卡推进和重开行为不变,并在修改后通过静态自检。', - [ - 'game/index.html 的 canvas 在桌面与移动视口可见且有非空像素', - 'lane-defense-v1 全部固定试玩断言继续通过', - '修改后的当前 revision 通过 game.static_smoke', - ], - ['game/index.html'], - ), - ]); + parentStage = 'await-manifest'; + return callsResponse('project-supervisor', tools, [goalContractCall()]); + } + + const advertisedProjectMutation = [...tools].some((tool) => + projectMutationFunctionNames.has(tool), + ); + const advertisedSafeExecution = [ + 'agent.goal_contract', + 'task.list', + 'agent.run_status', + 'command.run_limited', + 'preview.validate', + 'agent.action_history', + 'agent.acceptance_update', + ].some((tool) => tools.has(runtimeFunction(tool))); + if (advertisedProjectMutation && !advertisedSafeExecution) { + if ( + tools.has('respond_to_user') && + context.includes('当前父 run 已进入只编排模式') + ) { + return callsResponse('project-supervisor', tools, [ + nativeResponse('专业任务尚未收敛,继续等待 Runtime 完成门复核。'), + ]); + } + throw providerError('provider-supervisor-direct-mutation-denied'); } switch (parentStage) { - case 'initial-delegation': { - parentStage = 'claim-initial'; - stats.initialDelegationCount += 3; - return callsResponse('project-supervisor', tools, [ - delegateCall( - 'code-prototype', - '生成一个紧凑但完整的植物塔防网页游戏:支持开始、选择植物、放置防御单位、敌人移动和受伤、胜利、下一关与重开;写入 game/index.html,并在修改后通过静态自检。', - [ - 'game/index.html 提供完整 lane-defense-v1 状态面和六个可点击控件', - '敌人会移动并受伤,关卡可胜利、进入下一关并重开', - '修改后的当前 revision 通过 game.static_smoke', - ], - ['game/index.html'], - ), - delegateCall( - 'quality-review', - '执行只读验收:独立核对塔防交付合同是否覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回合同验收重点,不要修改任何文件,也不要读取或依赖并行 code-prototype 尚未完成的项目产物。', - [ - '验收结论覆盖完整可玩循环和双视口', - '只读返回合同重点且项目 mutation 为零', - ], - [], - ), - delegateCall( - 'art-asset-plan', - '生成原创首版核心美术素材并写入正式资产清单;必须调用 canvas.asset_generate 生成 assets/art-spritesheet.png,并写入可解析的 assets/manifest.art.json,不得用占位文件代替。', - [ - 'assets/art-spritesheet.png 是可解码的原创 1:1 PNG', - 'assets/manifest.art.json 可解析并登记该图片', - '生成结果已通过资产列表核对', - ], - ['assets/manifest.art.json', 'assets/art-spritesheet.png'], - ), - ]); - } - case 'claim-initial': - parentStage = 'static-initial'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - runStatusCall('认领两份首轮专业回执'), - ]); - case 'static-initial': - parentStage = 'preview-initial'; - stats.staticSmokeCount += 1; - return callsResponse('project-supervisor', tools, [ - staticSmokeCall('验证初版当前 revision'), - ]); - case 'preview-initial': - parentStage = 'attempt-forbidden-repair'; - stats.previewValidationCount += 1; - return callsResponse('project-supervisor', tools, [ - previewCall('真实试玩并检查桌面与移动视口'), - ]); - case 'attempt-forbidden-repair': - if (observationContext(context).includes('浏览器验证已通过')) { - parentStage = 'respond'; - return parentCalls(context, tools); - } - parentStage = 'await-delegation-repair'; - stats.supervisorDirectMutationAttemptCount += 1; - return callsResponse('project-supervisor', tools, [ - repairPatchCall('根据浏览器失败诊断直接修复不可见 canvas'), - ]); - case 'verify-repair': - parentStage = 'await-manifest'; - stats.runStatusCount += 1; - return callsResponse('project-supervisor', tools, [ - runStatusCall('先认领返工后的专业交付,再验证最新 revision'), - ]); case 'await-manifest': - if (!tools.has(runtimeFunction('task.list'))) { - if (tools.has(runtimeFunction('command.run_limited'))) { - stats.staticSmokeCount += 1; - return callsResponse('project-supervisor', tools, [ - staticSmokeCall('刷新并行产物修改后的当前 revision 验证凭证'), - ]); - } - if (tools.has(runtimeFunction('preview.validate'))) { - parentStage = 'respond'; - stats.previewValidationCount += 1; - return callsResponse('project-supervisor', tools, [ - previewCall('对最终 current revision 完成双视口交互试玩'), - ]); - } - } if (!manifestTasksCompleted(context)) { + if ( + !tools.has(runtimeFunction('task.list')) || + !tools.has(runtimeFunction('agent.run_status')) + ) { + throw providerError('provider-manifest-wait-tools-invalid'); + } stats.runStatusCount += 1; return callsResponse('project-supervisor', tools, [ nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}), runStatusCall('等待并读取并行专业任务的最新状态'), ]); } - parentStage = 'verify-final'; + parentStage = 'final-static-smoke'; return parentCalls(context, tools); - case 'verify-final': - parentStage = 'respond'; + case 'final-static-smoke': + if (!tools.has(runtimeFunction('command.run_limited'))) { + throw providerError('provider-final-static-smoke-tool-missing'); + } + parentStage = 'final-preview'; stats.staticSmokeCount += 1; - stats.previewValidationCount += 1; return callsResponse('project-supervisor', tools, [ staticSmokeCall('验证全部正式产物整合后的当前 revision'), + ]); + case 'final-preview': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'command.run_limited' || + observation?.status !== 'ok' + ) { + throw providerError( + 'provider-final-static-smoke-observation-invalid', + ); + } + if (!tools.has(runtimeFunction('preview.validate'))) { + throw providerError('provider-final-preview-tool-missing'); + } + parentStage = 'final-action-history'; + stats.previewValidationCount += 1; + return callsResponse('project-supervisor', tools, [ previewCall('对最终 current revision 重跑完整真实试玩和双视口检查'), ]); - case 'respond': + } + case 'final-action-history': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'preview.validate' || + observation?.status !== 'ok' + ) { + throw providerError('provider-final-preview-observation-invalid'); + } + if (!tools.has(runtimeFunction('agent.action_history'))) { + throw providerError('provider-final-action-history-tool-missing'); + } + parentStage = 'final-acceptance-update'; + return callsResponse('project-supervisor', tools, [ + actionHistoryCall(), + ]); + } + case 'final-acceptance-update': + if (!tools.has(runtimeFunction('agent.acceptance_update'))) { + throw providerError('provider-final-acceptance-update-tool-missing'); + } + { + const call = acceptanceUpdateCall( + context, + extractIdentity(context).runId, + ); + parentStage = 'final-respond'; + return callsResponse('project-supervisor', tools, [call]); + } + case 'final-respond': { + const observation = latestToolObservation(context); + if ( + observation?.tool !== 'agent.acceptance_update' || + observation?.status !== 'ok' + ) { + throw providerError('provider-final-acceptance-observation-invalid'); + } + if (!tools.has('respond_to_user')) { + throw providerError('provider-final-respond-tool-missing'); + } parentStage = 'done'; return callsResponse('project-supervisor', tools, [ - ...completedPlanCallsForContext( - context, - '可试玩项目和全部验证已经完成', - ), nativeResponse( - '可试玩塔防版本已经完成,并通过静态检查、完整交互试玩以及桌面和移动视口验证。', + '可试玩塔防版本已经完成,并通过当前 revision 静态检查、完整交互试玩以及桌面和移动视口验证。', ), ]); + } case 'done': - if (tools.has(runtimeFunction('task.list'))) { - parentStage = 'await-manifest'; - return parentCalls(context, tools); - } - if ( - tools.has(runtimeFunction('command.run_limited')) && - tools.has(runtimeFunction('preview.validate')) - ) { - parentStage = 'verify-final'; - return parentCalls(context, tools); - } - if (tools.has('respond_to_user')) { - return callsResponse('project-supervisor', tools, [ - nativeResponse( - '可试玩塔防版本已经完成,并通过正式产物检查、静态检查和双视口交互试玩。', - ), - ]); - } - throw providerError('provider-parent-done-tools-invalid'); + throw providerError('provider-parent-terminal-duplicate'); + case 'goal-contract': + throw providerError('provider-goal-contract-required-first'); default: throw providerError(`provider-parent-stage-invalid:${parentStage}`); } @@ -1369,12 +1482,6 @@ export function createDeterministicLaneDefenseRouter({ fileReadCall('memory/project.md', '读取长期项目约束'), ], ], - 'art-director': [ - [ - fileReadCall('game/game_design.md', '读取玩法规格以确定视觉方向'), - assetListCall('核对当前已有美术资产'), - ], - ], 'art-polish': [ [ fileReadCall( @@ -1428,6 +1535,43 @@ export function createDeterministicLaneDefenseRouter({ ); } + function artDirectorReadyCalls(runId, tools, context) { + const agentId = 'art-director'; + const phase = ensureReadyTaskRun(runId, agentId); + if (phase === 0) { + return readyCallsResponse(agentId, runId, tools, [ + assetListCall('核对当前已有美术资产'), + ]); + } + if (phase === 1) { + if (!tools.has(runtimeFunction('canvas.asset_generate'))) { + throw providerError('provider-ready-art-director-canvas-tool-missing'); + } + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if (phase === 2) { + if ( + tools.has('respond_to_user') && + !tools.has(runtimeFunction('asset.list')) + ) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + return readyCallsResponse(agentId, runId, tools, [ + assetListCall('核对统一视觉规范图已经生成并登记'), + ]); + } + if (phase === 3) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + if (phase >= 4) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + throw providerError(`provider-ready-art-director-stage-invalid:${phase}`); + } + function designFoundationReadyCalls(runId, tools, context) { const agentId = 'design-foundation'; const phase = ensureReadyTaskRun(runId, agentId); @@ -1590,93 +1734,6 @@ export function createDeterministicLaneDefenseRouter({ ); } - function initialArtDelegationCalls(runId, tools, context) { - const agentId = 'art-asset-plan'; - const existingKind = runKinds.get(runId); - if (!existingKind) { - runKinds.set(runId, 'initial-art-delegation'); - runPhases.set(runId, 0); - } else if (existingKind !== 'initial-art-delegation') { - throw providerError(`provider-initial-art-run-kind-invalid:${runId}`); - } - const phase = runPhases.get(runId) ?? 0; - const respond = (calls) => { - runPhases.set(runId, phase + 1); - return callsResponse(agentId, tools, calls); - }; - if (phase === 0) { - return respond([assetListCall('核对首版核心美术是否已经生成')]); - } - if (phase === 1) { - stats.sourceWriteCount += 1; - return respond([ - fileWriteCall( - 'assets/manifest.art.json', - `${JSON.stringify(deterministicArtManifest(true), null, 2)}\n`, - '写入登记首版核心图片的正式美术资产清单', - ), - ]); - } - if (phase === 2) { - if (!tools.has(runtimeFunction('canvas.asset_generate'))) { - throw providerError('provider-initial-art-canvas-tool-missing'); - } - return respond([canvasAssetCall(agentId)]); - } - if (phase === 3) { - return respond([ - fileReadCall('assets/manifest.art.json', '回读并核对正式美术清单'), - assetListCall('确认核心美术图片已生成并登记'), - ]); - } - if (phase === 4) { - terminalReplayAvailableRuns.add(runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并核对。'), - ]); - } - if (successfulCommandObservation(context) && tools.has('respond_to_user')) { - consumeTerminalReplayAuthorization(agentId, runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成并通过验证', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - agentId, - runId, - tools, - '补充验证首版核心美术和正式资产清单的当前 revision', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization(agentId, runId); - return respond([ - ...completedPlanCallsForContext( - context, - '首版核心美术和正式资产清单已经完成并通过验证', - ), - nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'), - ]); - } - throw providerError('provider-initial-art-terminal-duplicate'); - } - function artAssetReadyCalls(runId, tools, context) { const agentId = 'art-asset-plan'; const phase = ensureReadyTaskRun(runId, agentId); @@ -1693,21 +1750,16 @@ export function createDeterministicLaneDefenseRouter({ const hasCanvas = tools.has(runtimeFunction('canvas.asset_generate')); const willGenerate = hasCanvas && !existing; const hasVisualAsset = existing || willGenerate; - const calls = [ + stats.sourceWriteCount += 1; + stats.manifestReadyTaskFileWriteCount += 1; + runData.set(runId, { existing, hasCanvas, willGenerate }); + return readyCallsResponse(agentId, runId, tools, [ fileWriteCall( 'assets/manifest.art.json', `${JSON.stringify(deterministicArtManifest(hasVisualAsset, 'manifest-ready'), null, 2)}\n`, '写入可解析的首版美术资产清单', ), - ]; - stats.sourceWriteCount += 1; - stats.manifestReadyTaskFileWriteCount += 1; - if (willGenerate) { - calls.push(canvasAssetCall(agentId)); - stats.manifestReadyTaskCanvasGenerationCount += 1; - } - runData.set(runId, { existing, hasCanvas, willGenerate }); - return readyCallsResponse(agentId, runId, tools, calls); + ]); } if (phase === 2) { const data = runData.get(runId) ?? { @@ -1715,11 +1767,17 @@ export function createDeterministicLaneDefenseRouter({ hasCanvas: false, willGenerate: false, }; + if (data.willGenerate) { + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } const calls = [ fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), ]; stats.manifestReadyTaskFileReadCount += 1; - if (data.willGenerate) { + if (data.existing) { calls.push(assetListCall('核对新图片已登记到项目资产清单')); } else { calls.push(staticSmokeCall('验证美术清单写入后的当前 revision')); @@ -1728,11 +1786,9 @@ export function createDeterministicLaneDefenseRouter({ } return readyCallsResponse(agentId, runId, tools, calls); } - if (phase === 3) { - return readyTaskCompleteResponse(agentId, runId, tools, context); - } - if (phase >= 4) { + if (phase >= 3) { if ( + phase >= 5 && !tools.has('respond_to_user') && !tools.has(runtimeFunction('command.run_limited')) && tools.has(runtimeFunction('file.write')) @@ -1747,6 +1803,51 @@ export function createDeterministicLaneDefenseRouter({ ), ]); } + if (completedReadyTaskRuns.has(runId)) { + return readyTaskCompleteResponse(agentId, runId, tools, context); + } + const data = runData.get(runId) ?? { + existing: false, + hasCanvas: false, + willGenerate: false, + }; + if ( + data.willGenerate && + projectRevisionDriftObservation(context, 'canvas.asset_generate') + ) { + if (!tools.has(runtimeFunction('canvas.asset_generate'))) { + throw providerError('provider-ready-art-canvas-tool-missing'); + } + const retryCount = readyTaskPreCompletionRetryCounts.get(runId) ?? 0; + if (retryCount >= 16) { + throw providerError('provider-ready-art-canvas-retry-exhausted'); + } + readyTaskPreCompletionRetryCounts.set(runId, retryCount + 1); + stats.manifestReadyTaskCanvasGenerationCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + canvasAssetCall(agentId), + ]); + } + if (data.willGenerate) { + const observation = latestToolObservation(context); + if ( + observation?.tool === 'canvas.asset_generate' && + observation?.status === 'ok' + ) { + stats.manifestReadyTaskFileReadCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), + assetListCall('核对新图片已登记到项目资产清单'), + ]); + } + if (phase === 3) { + stats.manifestReadyTaskFileReadCount += 1; + return readyCallsResponse(agentId, runId, tools, [ + fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'), + assetListCall('核对新图片已登记到项目资产清单'), + ]); + } + } return readyTaskCompleteResponse(agentId, runId, tools, context); } throw providerError(`provider-ready-art-stage-invalid:${phase}`); @@ -1770,6 +1871,7 @@ export function createDeterministicLaneDefenseRouter({ fileReadCall('assets/manifest.art.json', '读取美术资产清单'), fileReadCall('assets/manifest.audio.json', '读取声音事件清单'), fileReadCall('game/index.html', '读取当前入口以保留既有试玩行为'), + assetListCall('核对 Canvas 登记、图集与四类切片的权威资产身份'), ]); } if (phase === 2) { @@ -1859,7 +1961,6 @@ export function createDeterministicLaneDefenseRouter({ [ 'design-director', 'balance-director', - 'art-director', 'art-polish', 'audio-director', 'code-director', @@ -1868,6 +1969,9 @@ export function createDeterministicLaneDefenseRouter({ ) { return readOnlyReadyCalls(agentId, runId, tools, context); } + if (agentId === 'art-director') { + return artDirectorReadyCalls(runId, tools, context); + } if (agentId === 'design-foundation') { return designFoundationReadyCalls(runId, tools, context); } @@ -1907,223 +2011,6 @@ export function createDeterministicLaneDefenseRouter({ throw providerError(`provider-ready-agent-unsupported:${agentId}`); } - function codeCalls(runId, tools, context) { - if (!runKinds.has(runId)) { - if (!initialBuilderRunId) { - initialBuilderRunId = runId; - runKinds.set(runId, 'initial-builder'); - } else if (!repairBuilderRunId) { - repairBuilderRunId = runId; - runKinds.set(runId, 'repair-builder'); - } else { - throw providerError('provider-extra-builder-run'); - } - runPhases.set(runId, 0); - } - const kind = runKinds.get(runId); - const phase = runPhases.get(runId); - if (kind === 'initial-builder') { - if (phase === 0) { - runPhases.set(runId, 1); - stats.sourceWriteCount += 1; - return callsResponse('code-prototype', tools, [ - nativeAction('file.write', '写入完整可玩的塔防初版', { - path: 'game/index.html', - content: deterministicLaneDefenseInitialHtml(), - }), - ]); - } - if (phase === 1) { - runPhases.set(runId, 2); - stats.staticSmokeCount += 1; - return callsResponse('code-prototype', tools, [ - staticSmokeCall('验证初版源码与状态面'), - ]); - } - if (phase === 2) { - runPhases.set(runId, 3); - terminalReplayAvailableRuns.add(runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse('初版塔防玩法与交互状态面已经生成,并通过静态自检。'), - ]); - } - if (phase >= 3) { - if ( - successfulCommandObservation(context) && - tools.has('respond_to_user') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse( - '初版塔防玩法与交互状态面已经生成,并通过静态自检。', - ), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - 'code-prototype', - runId, - tools, - '重试验证初版源码与状态面', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '初版实现和静态验证已经完成', - ), - nativeResponse( - '初版塔防玩法与交互状态面已经生成,并通过静态自检。', - ), - ]); - } - } - } - if (kind === 'repair-builder') { - if (phase === 0) { - runPhases.set(runId, 1); - stats.sourcePatchCount += 1; - return callsResponse('code-prototype', tools, [ - repairPatchCall('修复真实浏览器发现的 canvas 可见性'), - ]); - } - if (phase === 1) { - runPhases.set(runId, 2); - stats.staticSmokeCount += 1; - return callsResponse('code-prototype', tools, [ - staticSmokeCall('验证返工后的源码'), - ]); - } - if (phase === 2) { - runPhases.set(runId, 3); - terminalReplayAvailableRuns.add(runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - if (phase >= 3) { - if ( - successfulCommandObservation(context) && - tools.has('respond_to_user') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - if ( - specialistVerificationRepairContext(context) || - revisionBlockedObservation(context) || - transientCommandObservation(context) - ) { - return retryTerminalStaticVerification( - 'code-prototype', - runId, - tools, - '重试验证返工后的源码', - ); - } - if ( - tools.has('respond_to_user') && - context.includes('当前 revision 已通过验证') - ) { - consumeTerminalReplayAuthorization('code-prototype', runId); - return callsResponse('code-prototype', tools, [ - ...completedPlanCallsForContext( - context, - '画布可见性返工和静态验证已经完成', - ), - nativeResponse( - '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。', - ), - ]); - } - } - } - throw providerError(`provider-builder-stage-invalid:${kind}:${phase}`); - } - - function qualityCalls(runId, tools, context) { - const phase = runPhases.get(runId) ?? 0; - if (phase === 0) { - runKinds.set(runId, 'quality-review'); - runPhases.set(runId, 1); - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext(context, '独立验收重点已经核对'), - nativeResponse( - '验收必须以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - if (phase === 1) { - if ( - !revisionBlockedObservation(context) && - !context.includes('当前专业合同明确要求只读交付') - ) { - throw providerError('provider-quality-terminal-duplicate'); - } - consumeUniqueTerminalObservation('quality-review', runId, context); - runPhases.set(runId, 2); - stats.qualityRevisionReplanCount += 1; - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext( - context, - '项目 revision 更新后已重新核对独立验收重点', - ), - nativeResponse( - '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - if ( - phase >= 2 && - tools.has('respond_to_user') && - (revisionBlockedObservation(context) || - context.includes('当前专业合同明确要求只读交付')) - ) { - consumeUniqueTerminalObservation('quality-review', runId, context); - return callsResponse('quality-review', tools, [ - ...completedPlanCallsForContext( - context, - '项目 revision 更新后已重新核对独立验收重点', - ), - nativeResponse( - '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', - ), - ]); - } - throw providerError(`provider-quality-stage-invalid:${phase}`); - } - function finalReply(identity, context) { const key = `${identity.agentId}\0${identity.runId}`; if (finalReplyRuns.has(key)) { @@ -2135,6 +2022,9 @@ export function createDeterministicLaneDefenseRouter({ ) { throw providerError('provider-unexpected-text-request'); } + if (identity.agentId === 'project-supervisor' && parentStage !== 'done') { + throw providerError('provider-parent-final-reply-before-acceptance'); + } finalReplyRuns.add(key); const content = identity.agentId === 'project-supervisor' @@ -2175,6 +2065,18 @@ export function createDeterministicLaneDefenseRouter({ context.includes('用户这轮输入:') && tools.has('runtime_execute') ) { + if (payload.reasoning_effort !== 'max') { + throw providerError('provider-interaction-reasoning-effort-invalid'); + } + if (stats.interactionExecuteCount !== 0) { + throw providerError('provider-interaction-execute-duplicate'); + } + if ( + stats.manifestReadyTaskRunCount !== 0 || + stats.manifestReadyTaskCompletionCount !== 0 + ) { + throw providerError('provider-interaction-execute-out-of-order'); + } stats.planningRequestCount += 1; stats.interactionExecuteCount += 1; recordAgent('project-supervisor', 'planning'); @@ -2190,17 +2092,17 @@ export function createDeterministicLaneDefenseRouter({ model, JSON.stringify({ checks: { - resourceBar: true, - unitCardTray: true, - battlefieldGrid: true, - enemyEntryDirection: true, - waveStatus: true, + informationHud: true, + gameplaySurface: true, + objectiveEntities: true, primaryControls: true, + failureRestartFlow: true, + responsiveLayout: true, implementationClarity: true, originalTheme: true, }, issues: [], - summary: '确定性 UI 原型覆盖完整横屏塔防实现合同。', + summary: '确定性 UI 原型覆盖完整玩法界面实现合同。', }), ); } @@ -2253,15 +2155,6 @@ export function createDeterministicLaneDefenseRouter({ if (identity.agentId === 'project-supervisor') { return parentCalls(context, tools); } - if (identity.agentId === 'code-prototype') { - return codeCalls(identity.runId, tools, context); - } - if (identity.agentId === 'quality-review') { - return qualityCalls(identity.runId, tools, context); - } - if (identity.agentId === 'art-asset-plan') { - return initialArtDelegationCalls(identity.runId, tools, context); - } throw providerError(`provider-agent-unsupported:${identity.agentId}`); } catch (error) { stats.unexpectedRequestCount += 1; @@ -2376,6 +2269,7 @@ function createDeterministicCanvasFixture(apiKey) { canvasApiRequestCount: 0, canvasGenerationRequestCount: 0, canvasDownloadRequestCount: 0, + canvasSliceDownloadRequestCount: 0, canvasGeneratedAspectRatios: {}, }; let projectTitle = null; @@ -2473,6 +2367,137 @@ function createDeterministicCanvasFixture(apiKey) { : '未命名游戏原型'; return json(200, { data: { folder: { folderId, label: folderLabel } } }); } + if ( + request.method === 'POST' && + parsed.pathname === + '/api/external/v1/editor/icon-spritesheets/generations' + ) { + const idempotencyKey = request.headers['idempotency-key']; + if ( + typeof idempotencyKey !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + idempotencyKey, + ) + ) { + request.resume(); + return json(400, { error: { message: 'invalid idempotency key' } }); + } + const body = await readJsonBody(request); + const aspectRatio = body?.aspectRatio === '16:9' ? '16:9' : '1:1'; + const width = aspectRatio === '16:9' ? 1280 : 1024; + const height = aspectRatio === '16:9' ? 720 : 1024; + generationSequence += 1; + stats.canvasGenerationRequestCount += 1; + stats.canvasGeneratedAspectRatios[aspectRatio] = + (stats.canvasGeneratedAspectRatios[aspectRatio] ?? 0) + 1; + const imageId = `deterministic-${generationSequence}`; + const objectKey = `generated/deterministic/${imageId}.png`; + const assetObjectId = `asset-object-${imageId}`; + const resourceId = `resource-${imageId}`; + const taskId = `task-${imageId}`; + images.set(imageId, { + aspectRatio, + width, + height, + bytes: deterministicPng(width, height, { + variant: generationSequence, + transparent: true, + }), + objectKey, + downloadKind: 'generation', + }); + const sliceNames = ['玩家主体', '目标与危险物', '场景与资源', '反馈特效']; + const iconImageSrcs = sliceNames.map((name, index) => { + const sliceImageId = `${imageId}-slice-${index + 1}`; + const sliceObjectKey = `generated/deterministic/${sliceImageId}.png`; + const sliceResourceId = `resource-${sliceImageId}`; + const sliceAssetObjectId = `asset-object-${sliceImageId}`; + images.set(sliceImageId, { + aspectRatio: '1:1', + width: 256, + height: 256, + bytes: deterministicPng(256, 256, { + variant: generationSequence * 10 + index + 1, + }), + objectKey: sliceObjectKey, + downloadKind: 'slice', + }); + return { + name, + imageSrc: `/${sliceObjectKey}`, + width: 256, + height: 256, + projectId, + taskId, + sourceResourceId: resourceId, + resource: { + resourceId: sliceResourceId, + projectId, + imageSrc: `/${sliceObjectKey}`, + objectKey: sliceObjectKey, + assetObjectId: sliceAssetObjectId, + taskId, + sourceResourceId: resourceId, + }, + asset: { + assetId: `asset-${sliceImageId}`, + assetObjectId: sliceAssetObjectId, + assetKind: 'art-spritesheet-slice', + projectId, + taskId, + }, + }; + }); + const operationId = `task-${imageId}`; + generationOperations.set(operationId, { + spritesheetImageSrc: `/${objectKey}`, + objectKey, + assetObjectId, + resourceId, + projectId, + taskId, + width, + height, + sourceType: 'generated', + prompt: + body?.iconDescriptions?.join('\n') ?? + 'deterministic spritesheet fixture', + actualPrompt: + body?.iconDescriptions?.join('\n') ?? + 'deterministic spritesheet fixture', + model: 'deterministic-canvas-v1', + provider: 'deterministic-loopback', + spritesheetResource: { + resourceId, + projectId, + imageSrc: `/${objectKey}`, + objectKey, + assetObjectId, + width, + height, + sourceType: 'generated', + taskId, + }, + spritesheetAsset: { + assetId: `asset-${imageId}`, + assetObjectId, + assetKind: 'art-spritesheet', + projectId, + taskId, + }, + iconImageSrcs, + }); + return json(202, { + data: { + operationId, + kind: 'editor_icon_spritesheet_generation', + status: 'queued', + statusUrl: `/api/external/v1/generations/${operationId}`, + pollAfterMs: 1, + updatedAtMicros: generationSequence, + }, + }); + } if ( request.method === 'POST' && parsed.pathname === '/api/external/v1/editor/images/generations' @@ -2499,7 +2524,11 @@ function createDeterministicCanvasFixture(apiKey) { const resourceId = `resource-${imageId}`; const assetKind = typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; - images.set(imageId, { ...image, objectKey }); + images.set(imageId, { + ...image, + objectKey, + downloadKind: 'generation', + }); const operationId = `task-${imageId}`; generationOperations.set(operationId, { imageSrc: `/${objectKey}`, @@ -2555,7 +2584,8 @@ function createDeterministicCanvasFixture(apiKey) { '/api/external/v1/generations/'.length, ); const result = generationOperations.get(operationId); - if (!result) return json(404, { error: { message: 'operation not found' } }); + if (!result) + return json(404, { error: { message: 'operation not found' } }); return json(200, { data: { operationId, @@ -2600,7 +2630,11 @@ function createDeterministicCanvasFixture(apiKey) { const imageId = parsed.pathname.slice('/signed/'.length, -'.png'.length); const image = images.get(imageId); if (!image) return json(404, { error: { message: 'image not found' } }); - stats.canvasDownloadRequestCount += 1; + if (image.downloadKind === 'slice') { + stats.canvasSliceDownloadRequestCount += 1; + } else { + stats.canvasDownloadRequestCount += 1; + } return { statusCode: 200, contentType: 'image/png', diff --git a/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs new file mode 100644 index 000000000..09269cdc6 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs @@ -0,0 +1,139 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import readline from 'node:readline'; + +const configPath = + process.env.AGC_CONFIG_PATH ?? + 'C:/Users/kdletters/AppData/Roaming/world.genarrative.ai-game-creator/game-creator.config.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +const args = [ + 'app-server', + '--stdio', + '-c', + 'mcp_servers={}', + '-c', + 'web_search="disabled"', + '-c', + 'agents.enabled=false', +]; +for (const feature of [ + 'apps', + 'browser_use', + 'browser_use_external', + 'browser_use_full_cdp_access', + 'computer_use', + 'goals', + 'image_generation', + 'in_app_browser', + 'plugins', + 'remote_plugin', + 'shell_tool', + 'tool_suggest', + 'unified_exec', + 'workspace_dependencies', +]) + args.push('--disable', feature); +args.push( + '-c', + 'model_provider="genarrative_agc"', + '-c', + 'model_providers.genarrative_agc.name="Genarrative AGC"', + '-c', + `model_providers.genarrative_agc.base_url="${config.llm.baseUrl.replace(/\/$/, '')}"`, + '-c', + 'model_providers.genarrative_agc.env_key="GENARRATIVE_AGC_API_KEY"', + '-c', + 'model_providers.genarrative_agc.wire_api="responses"', +); +const child = spawn( + 'C:/Program Files/nodejs/node.exe', + [ + 'C:/Users/kdletters/AppData/Roaming/npm/node_modules/@openai/codex/bin/codex.js', + ...args, + ], + { + cwd: process.cwd(), + env: { ...process.env, GENARRATIVE_AGC_API_KEY: config.llm.apiKey }, + stdio: ['pipe', 'pipe', 'pipe'], + }, +); +const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`); +const timer = setTimeout( + () => { + console.log('smoke-timeout'); + child.kill(); + process.exit(1); + }, + Number(process.env.DIRECT_SMOKE_TIMEOUT_MS ?? 180000), +); +readline.createInterface({ input: child.stdout }).on('line', (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.id === 2) { + console.log('thread-start-ok'); + const write = process.env.DIRECT_SMOKE_WRITE === '1'; + send({ + id: 3, + method: 'turn/start', + params: { + threadId: message.result.thread.id, + input: [ + { + type: 'text', + text: + process.env.DIRECT_SMOKE_PROMPT ?? + (write + ? '在当前工作区创建 smoke-marker.txt,写入 DIRECT_CODEX_WRITE_OK,然后回复已完成。' + : '只回复 DIRECT_CODEX_SMOKE_OK,不要修改文件。'), + }, + ], + model: config.llm.model, + approvalPolicy: 'never', + sandboxPolicy: write + ? { + type: 'workspaceWrite', + writableRoots: [process.cwd()], + networkAccess: true, + } + : { type: 'readOnly', networkAccess: false }, + }, + }); + } + if (message.id === 3 && message.error) { + console.log(`turn-start-error:${message.error.message}`); + clearTimeout(timer); + child.kill(); + process.exit(1); + } + if (message.method === 'turn/completed') { + console.log(`turn-completed:${message.params?.turn?.status}`); + clearTimeout(timer); + child.kill(); + process.exit(message.params?.turn?.status === 'completed' ? 0 : 1); + } +}); +send({ + id: 1, + method: 'initialize', + params: { + clientInfo: { name: 'agc-direct-smoke', version: '0.1' }, + capabilities: { experimentalApi: true }, + }, +}); +send({ method: 'initialized', params: {} }); +send({ + id: 2, + method: 'thread/start', + params: { + cwd: process.cwd(), + model: config.llm.model, + approvalPolicy: 'never', + sandbox: + process.env.DIRECT_SMOKE_WRITE === '1' ? 'workspace-write' : 'read-only', + ephemeral: true, + }, +}); diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs index 355e29197..db7c8d42c 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs @@ -47,7 +47,7 @@ function isAiGameCreatorServer(response) { response && response.statusCode >= 200 && response.statusCode < 500 && - response.body.includes('AI 游戏创作') && + response.body.includes('陶泥儿') && response.body.includes('/src/main.tsx') ); } diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 2bec7f09e..687587b18 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -158,7 +158,7 @@ function isAiGameCreatorServer(response) { response && response.statusCode >= 200 && response.statusCode < 500 && - response.body.includes('AI 游戏创作') && + response.body.includes('陶泥儿') && response.body.includes('/src/main.tsx') ); } diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 50d312f47..c8255bc4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -11,7 +11,13 @@ "core:resources:allow-close", { "identifier": "http:default", - "allow": [{ "url": "https://dev.genarrative.world/api/*" }] + "allow": [ + { "url": "https://dev.genarrative.world/api/*" }, + { "url": "https://www.genarrative.world/api/*" }, + { "url": "https://*/api/*" }, + { "url": "http://localhost:*/*" }, + { "url": "http://127.0.0.1:*/*" } + ] }, "opener:default" ] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 8bda8dd61..8020721aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; +mod direct_runtime; mod generation; mod interaction; mod prompt; @@ -20,11 +21,13 @@ mod runtime_driver; mod runtime_protocol; mod runtime_state; mod runtime_tools; +pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; use codex_app_server::*; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; +pub(crate) use direct_runtime::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; 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 29ff244fe..6be5feca1 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 @@ -1,4 +1,5 @@ use super::*; +use platform_llm::LlmMessageRole; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::process::Stdio; @@ -21,6 +22,8 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX "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 = + "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."; type RpcResult = Result; @@ -317,6 +320,7 @@ struct CodexAppServerInner { last_used: AtomicU64, closed: AtomicBool, _working_dir: tempfile::TempDir, + workspace_path: std::path::PathBuf, } #[derive(Clone)] @@ -370,6 +374,27 @@ fn game_creator_codex_app_server_pool_key( format!("{:x}", Sha256::digest(stable.to_string().as_bytes())) } +fn direct_codex_base_instructions(request: &LlmRunRequest) -> String { + request + .messages + .iter() + .find(|message| message.role == LlmMessageRole::System) + .map(|message| message.content.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK) + .to_string() +} + +fn direct_codex_user_prompt(request: &LlmRunRequest) -> String { + request + .messages + .iter() + .filter(|message| message.role != LlmMessageRole::System) + .map(|message| message.content.as_str()) + .collect::>() + .join("\n\n") +} + fn find_game_creator_codex_auth_path() -> Option { std::env::var_os("CODEX_HOME") .map(std::path::PathBuf::from) @@ -450,6 +475,15 @@ fn game_creator_codex_app_server_validate_llm_config( Ok(()) } +fn apply_game_creator_codex_app_server_reasoning_effort( + params: &mut serde_json::Value, + request: &LlmRunRequest, +) { + if let Some(effort) = game_creator_codex_cli_reasoning_effort(request) { + params["effort"] = serde_json::Value::String(effort.to_string()); + } +} + fn quoted_toml_string(value: &str) -> Result { serde_json::to_string(value).map_err(|error| { platform_llm::LlmError::InvalidConfig(format!("序列化 Codex app-server 配置失败:{error}")) @@ -469,7 +503,7 @@ fn configure_game_creator_codex_app_server_command( .arg("web_search=\"disabled\"") .arg("-c") .arg("agents.enabled=false"); - for feature in [ + let disabled_features = [ "apps", "browser_use", "browser_use_external", @@ -480,13 +514,18 @@ fn configure_game_creator_codex_app_server_command( "in_app_browser", "plugins", "remote_plugin", - "shell_tool", "tool_suggest", - "unified_exec", "workspace_dependencies", - ] { + ]; + for feature in disabled_features { command.arg("--disable").arg(feature); } + // AGC does not implement an approval broker for shell execution. Keep the + // native file-change tool available for direct Codex turns, but disable + // shell/unified_exec so the model cannot fall back to command requests that + // the unattended direct bridge must reject. + command.arg("--disable").arg("shell_tool"); + command.arg("--disable").arg("unified_exec"); if !llm.api_key.trim().is_empty() { command .arg("-c") @@ -548,10 +587,32 @@ fn prepare_isolated_game_creator_codex_home( Ok(isolated_home) } +fn trust_isolated_game_creator_codex_workspace( + codex_home: &std::path::Path, + workspace: &std::path::Path, +) -> Result<(), platform_llm::LlmError> { + let workspace = workspace.to_string_lossy(); + let quoted_workspace = quoted_toml_string(&workspace)?; + let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n"); + std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入隔离 Codex app-server 项目信任配置失败:{error}" + )) + }) +} + impl CodexAppServerConnection { async fn acquire( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, + ) -> Result { + Self::acquire_at_workspace(snapshot, llm, None).await + } + + async fn acquire_at_workspace( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + workspace_override: Option<&std::path::Path>, ) -> Result { game_creator_codex_app_server_validate_llm_config(llm)?; let codex_cli_version = game_creator_codex_cli_version_identity() @@ -596,7 +657,20 @@ impl CodexAppServerConnection { .await; } } - let connection = Self::spawn(llm, &credential).await?; + let connection = match workspace_override { + Some(workspace) => { + let executable = game_creator_codex_cli_executable_path() + .map_err(platform_llm::LlmError::InvalidConfig)?; + Self::spawn_with_executable_and_credential_at_workspace( + llm, + &credential, + executable.as_os_str(), + Some(workspace), + ) + .await? + } + None => Self::spawn(llm, &credential).await?, + }; pool.insert(key, Arc::clone(&connection.inner)); Ok(connection) } @@ -622,6 +696,16 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, executable: &std::ffi::OsStr, + ) -> Result { + Self::spawn_with_executable_and_credential_at_workspace(llm, credential, executable, None) + .await + } + + async fn spawn_with_executable_and_credential_at_workspace( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + executable: &std::ffi::OsStr, + workspace_override: Option<&std::path::Path>, ) -> Result { let working_dir = tempfile::Builder::new() .prefix("genarrative-agc-codex-app-server-") @@ -634,16 +718,24 @@ impl CodexAppServerConnection { let isolated_codex_home = prepare_isolated_game_creator_codex_home(working_dir.path(), credential)?; let isolated_workspace = working_dir.path().join("workspace"); - std::fs::create_dir(&isolated_workspace).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "创建 Codex app-server 隔离工作目录失败:{error}" - )) - })?; - std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "创建 Codex app-server 隔离仓库边界失败:{error}" - )) - })?; + if workspace_override.is_none() { + std::fs::create_dir(&isolated_workspace).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离工作目录失败:{error}" + )) + })?; + std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离仓库边界失败:{error}" + )) + })?; + } + let workspace_path = workspace_override + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| isolated_workspace.clone()); + if workspace_override.is_some() { + trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?; + } let isolated_os_home = working_dir.path().join("home"); let isolated_app_data = isolated_os_home.join("appdata"); let isolated_local_app_data = isolated_os_home.join("local-appdata"); @@ -661,7 +753,7 @@ impl CodexAppServerConnection { let mut command = tokio::process::Command::new(executable); configure_game_creator_codex_app_server_command(&mut command, llm)?; command - .current_dir(&isolated_workspace) + .current_dir(&workspace_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -705,6 +797,7 @@ impl CodexAppServerConnection { last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()), closed: AtomicBool::new(false), _working_dir: working_dir, + workspace_path, }); tokio::spawn(read_game_creator_codex_app_server_stdout( Arc::downgrade(&inner), @@ -868,13 +961,25 @@ impl CodexAppServerConnection { .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(&llm.model); + let direct_workspace = + self.inner.workspace_path != self.inner._working_dir.path().join("workspace"); + let approval_policy = if direct_workspace { + "on-request" + } else { + "never" + }; + let base_instructions = if direct_workspace { + direct_codex_base_instructions(&request) + } else { + DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string() + }; let mut params = serde_json::json!({ "model": model, - "cwd": self.inner._working_dir.path().join("workspace"), - "approvalPolicy": "never", - "sandbox": "read-only", + "cwd": self.inner.workspace_path, + "approvalPolicy": approval_policy, + "sandbox": if direct_workspace { "workspace-write" } else { "read-only" }, "ephemeral": true, - "baseInstructions": "You are an isolated reasoning engine for Genarrative AGC. Never use native tools, shell, files, network, MCP, skills, plugins, hooks, or sub-agents. Use only the complete message snapshot provided in each turn and return only the requested answer contract." + "baseInstructions": base_instructions }); if !llm.api_key.trim().is_empty() { params["modelProvider"] = @@ -956,8 +1061,19 @@ impl CodexAppServerConnection { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; let thread_id = thread_lease.thread_id.clone(); - let prompt = render_game_creator_codex_cli_prompt(&request) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let direct_workspace = + self.inner.workspace_path != self.inner._working_dir.path().join("workspace"); + let approval_policy = if direct_workspace { + "on-request" + } else { + "never" + }; + let prompt = if direct_workspace { + direct_codex_user_prompt(&request) + } else { + render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)? + }; let model = request .model .as_deref() @@ -967,12 +1083,14 @@ impl CodexAppServerConnection { "threadId": thread_id, "input": [{ "type": "text", "text": prompt }], "model": model, - "approvalPolicy": "never", - "sandboxPolicy": { "type": "readOnly", "networkAccess": false }, + "approvalPolicy": approval_policy, + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [self.inner.workspace_path], + "networkAccess": true + }, }); - if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { - params["effort"] = serde_json::Value::String(effort.to_string()); - } + apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { params["outputSchema"] = schema; } @@ -1054,10 +1172,12 @@ impl CodexAppServerConnection { { final_text = Some(text.to_string()); } - } else if !matches!( - item_type, - "userMessage" | "plan" | "reasoning" | "contextCompaction" - ) { + } else if !direct_workspace + && !matches!( + item_type, + "userMessage" | "plan" | "reasoning" | "contextCompaction" + ) + { return Err(platform_llm::LlmError::InvalidRequest(format!( "Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}" ))); @@ -1319,17 +1439,148 @@ async fn read_game_creator_codex_app_server_stdout( let Some(inner) = inner.upgrade() else { return; }; + let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() + && inner.workspace_path != inner._working_dir.path().join("workspace"); + if direct_debug { + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !method.is_empty() { + let params_keys = message + .get("params") + .and_then(serde_json::Value::as_object) + .map(|params| params.keys().cloned().collect::>()) + .unwrap_or_default(); + eprintln!( + "agent.direct_codex.event method={} paramsKeys={:?}", + method, params_keys + ); + } else if let Some(params) = message.get("params") { + let event_name = params + .get("type") + .and_then(serde_json::Value::as_str) + .or_else(|| { + params + .get("item") + .and_then(|item| item.get("type")) + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("unknown"); + let status = params + .pointer("/turn/status") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + eprintln!( + "agent.direct_codex.event notification={} status={}", + event_name, status + ); + if event_name == "item/completed" || event_name == "item/started" { + if let Some(item) = params.get("item") { + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + let command = item + .get("command") + .and_then(serde_json::Value::as_str) + .map(|value| value.chars().take(512).collect::()); + let change_paths = item + .get("changes") + .and_then(serde_json::Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + change + .get("path") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect::>() + }); + eprintln!( + "agent.direct_codex.item event={} type={} command={:?} changePaths={:?}", + event_name, + item_type, + command, + change_paths + ); + } + } + } + } if let Some(id) = message.get("id").and_then(serde_json::Value::as_u64) { if message.get("method").is_some() { let writer = Arc::clone(&inner); tokio::spawn(async move { - let response = serde_json::json!({ - "id": id, - "error": { - "code": -32601, - "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + let direct_workspace = + inner.workspace_path != inner._working_dir.path().join("workspace"); + let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some(); + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let requested_grant_root = message + .get("params") + .and_then(|params| params.get("grantRoot")) + .and_then(serde_json::Value::as_str); + if direct_workspace && direct_debug { + eprintln!( + "agent.direct_codex.server_request method={method} grantRoot={requested_grant_root:?}" + ); + } + let file_change_within_workspace = + game_creator_codex_file_change_request_is_allowed( + &inner.workspace_path, + method, + requested_grant_root, + ); + let response = if direct_workspace + && file_change_within_workspace + && matches!( + method, + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/mcpToolCall/requestApproval" + | "item/permissions/requestApproval" + ) { + if method == "item/permissions/requestApproval" { + serde_json::json!({ + "id": id, + "result": { + "permissions": { + "fileSystem": { + "write": [inner.workspace_path] + }, + "network": { "enabled": true } + }, + "scope": "turn" + } + }) + } else { + serde_json::json!({ + "id": id, + "result": { "decision": "accept" } + }) } - }); + } else if direct_workspace && method == "item/fileChange/requestApproval" { + serde_json::json!({ + "id": id, + "error": { + "code": -32602, + "message": "Genarrative AGC 只允许当前项目工作区的文件变更审批" + } + }) + } else { + serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }) + }; let mut bytes = match serde_json::to_vec(&response) { Ok(bytes) => bytes, Err(_) => return, @@ -1488,6 +1739,17 @@ async fn read_game_creator_codex_app_server_stderr( let Some(upgraded) = inner.upgrade() else { return; }; + if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() + && upgraded.workspace_path != upgraded._working_dir.path().join("workspace") + { + let text = String::from_utf8_lossy(&buffer[..count]); + let bounded = text.chars().take(2_048).collect::(); + eprintln!( + "agent.direct_codex.stderr bytes={} text={}", + count, + redact_game_creator_codex_debug_text(&bounded) + ); + } let oversized_record = upgraded .stderr_summary .lock() @@ -1505,6 +1767,99 @@ async fn read_game_creator_codex_app_server_stderr( } } +fn redact_game_creator_codex_debug_text(text: &str) -> String { + let mut redacted = text.to_string(); + let mut bearer_search_from = 0; + while let Some(relative_start) = redacted[bearer_search_from..].find("Bearer ") { + let start = bearer_search_from + relative_start; + let value_start = start + "Bearer ".len(); + let value_end = redacted[value_start..] + .find(char::is_whitespace) + .map(|offset| value_start + offset) + .unwrap_or(redacted.len()); + redacted.replace_range(value_start..value_end, "[redacted]"); + bearer_search_from = value_start + "[redacted]".len(); + } + for marker in ["apiKey=", "api_key=", "token=", "access_token="] { + let mut search_from = 0; + while let Some(relative_start) = redacted[search_from..].find(marker) { + let start = search_from + relative_start; + let value_start = start + marker.len(); + let value_end = redacted[value_start..] + .find(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '}' | '"')) + .map(|offset| value_start + offset) + .unwrap_or(redacted.len()); + redacted.replace_range(value_start..value_end, "[redacted]"); + search_from = value_start + "[redacted]".len(); + } + } + redacted +} + +fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String { + let value = path.to_string_lossy(); + value + .strip_prefix("\\\\?\\") + .unwrap_or(value.as_ref()) + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} + +fn game_creator_codex_grant_root_is_within_workspace( + workspace: &std::path::Path, + grant_root: &str, +) -> bool { + fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return None; + } + let mut existing = path.to_path_buf(); + let mut missing_tail = Vec::new(); + while !existing.exists() { + missing_tail.push(existing.file_name()?.to_os_string()); + if !existing.pop() { + return None; + } + } + let mut normalized = existing.canonicalize().ok()?; + for component in missing_tail.iter().rev() { + normalized.push(component); + } + Some(normalized) + } + + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else { + return false; + }; + let workspace_key = game_creator_codex_workspace_path_key(&workspace); + let grant_key = game_creator_codex_workspace_path_key(&grant_root); + grant_key == workspace_key + || grant_key + .strip_prefix(&workspace_key) + .is_some_and(|suffix| suffix.starts_with('\\')) +} + +fn game_creator_codex_file_change_request_is_allowed( + workspace: &std::path::Path, + method: &str, + grant_root: Option<&str>, +) -> bool { + method != "item/fileChange/requestApproval" + // `grantRoot: null` means the existing turn sandbox root; only an + // explicit expansion request needs an extra path check. + || grant_root.is_none() + || grant_root + .is_some_and(|grant_root| game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)) +} + async fn fail_game_creator_codex_app_server_connection( inner: &Weak, error: String, @@ -1565,6 +1920,64 @@ pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( .await } +/// Direct product chat path: a Codex app-server turn in the selected project +/// workspace. The pool keeps one ephemeral thread per project so follow-up +/// messages retain Codex context while still bypassing the Supervisor/runtime +/// harness. +pub(crate) async fn direct_game_creator_codex_chat_at( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, +) -> Result { + let codex_root = if let Some(path) = root + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + { + std::path::PathBuf::from(path) + } else { + root.to_path_buf() + }; + let config = load_game_creator_app_config()?; + game_creator_codex_app_server_validate_llm_config(&config.llm) + .map_err(|error| error.to_string())?; + let mut digest = Sha256::new(); + digest.update(root.to_string_lossy().as_bytes()); + let project_id = format!("{:x}", digest.finalize()); + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: project_id.clone(), + agent_id: "direct-codex".to_string(), + task_id: "direct-codex-chat".to_string(), + session_id: format!("direct-codex-session-{project_id}"), + run_id: format!("direct-codex-thread-{project_id}"), + source: "direct-codex-chat".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "direct-chat".to_string(), + request_slot: "direct-chat".to_string(), + web_search_enabled: config.llm.web_search_enabled, + allow_idle_context_compaction: false, + planning_session_binding: None, + }; + let api_kind = + parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; + let request = LlmRunRequest::single_turn(system_prompt, user_prompt) + .with_api_kind(api_kind) + .with_model(config.llm.model.clone()) + .with_request_timeout_ms(config.llm.request_timeout_ms) + .with_max_output_tokens(16_000); + let connection = + CodexAppServerConnection::acquire_at_workspace(&snapshot, &config.llm, Some(&codex_root)) + .await + .map_err(|error| error.to_string())?; + connection + .run_turn(&snapshot, &config.llm, request, None) + .await + .map(|value| value.text) + .map_err(|error| error.to_string()) +} + pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -1647,6 +2060,23 @@ mod tests { ]) } + #[test] + fn direct_codex_turn_keeps_system_prompt_out_of_user_input() { + let request = LlmRunRequest::single_turn("AGC 系统规则", "制作一个可玩的游戏"); + assert_eq!(direct_codex_base_instructions(&request), "AGC 系统规则"); + assert_eq!(direct_codex_user_prompt(&request), "制作一个可玩的游戏"); + assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则")); + } + + #[test] + fn codex_app_server_reasoning_effort_preserves_max_wire_value() { + let request = LlmRunRequest::single_turn("系统", "任务") + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + let mut params = serde_json::json!({}); + apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); + assert_eq!(params["effort"], "max"); + } + #[test] fn codex_app_server_maps_structured_output_to_runtime_tool_calls() { let text = r#"{"toolCalls":[{"name":"runtime_tool_file_read","arguments":"{\"path\":\"game/index.html\"}"}]}"#; @@ -1743,6 +2173,66 @@ mod tests { assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); } + #[test] + fn direct_debug_redaction_does_not_retain_inline_credentials() { + let text = redact_game_creator_codex_debug_text( + "Bearer secret apiKey=one token=two access_token=three", + ); + assert!(!text.contains("secret")); + assert!(!text.contains("one")); + assert!(!text.contains("two")); + assert!(!text.contains("three")); + assert!(text.contains("[redacted]")); + } + + #[test] + fn direct_file_change_approval_is_limited_to_workspace() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("demo"); + let child = workspace.join("game"); + let sibling = temp.path().join("demolition"); + std::fs::create_dir_all(&child).expect("workspace child"); + std::fs::create_dir(&sibling).expect("sibling"); + assert!(game_creator_codex_file_change_request_is_allowed( + &workspace, + "item/fileChange/requestApproval", + None + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace.to_string_lossy().as_ref() + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + child.to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + sibling.to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_file_change_request_is_allowed( + &workspace, + "item/fileChange/requestApproval", + Some(sibling.to_string_lossy().as_ref()) + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + sibling.join("missing").to_string_lossy().as_ref() + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace.join("missing").to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace + .join("..") + .join("outside") + .to_string_lossy() + .as_ref() + )); + } + #[test] fn codex_app_server_command_uses_only_current_cli_feature_flags() { let mut command = tokio::process::Command::new("codex"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index ca5ce15c3..8155044a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -144,6 +144,7 @@ pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", + platform_llm::LlmResponseReasoningEffort::Max => "max", }) } @@ -666,6 +667,16 @@ mod tests { ]) } + #[test] + fn codex_cli_reasoning_effort_preserves_max_wire_value() { + let request = LlmRunRequest::single_turn("系统", "任务") + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + assert_eq!( + game_creator_codex_cli_reasoning_effort(&request), + Some("max") + ); + } + #[cfg(windows)] #[test] fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() { 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 new file mode 100644 index 000000000..053c13cfb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -0,0 +1,2870 @@ +use super::*; +use std::io::Write; +use std::path::Path; + +const MAX_CONTEXT_FILE_BYTES: usize = 6 * 1024; +const MAX_DIRECT_PROJECT_FILE_BYTES: usize = 24 * 1024; +const MAX_CONTEXT_FILES_PER_SOURCE: usize = 6; +const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024; +const BUNDLED_REPOSITORY_AGENTS: &str = include_str!("../../../../../AGENTS.md"); +const BUNDLED_AGC_IMPLEMENTATION_PLAN: &str = include_str!( + "../../../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md" +); +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"; +const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str = + "platform-generation-source-preserved-no-retry:"; +#[cfg(test)] +const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [ + ("player", "assets/art-spritesheet-slices/player.png"), + ( + "blocks-and-targets", + "assets/art-spritesheet-slices/blocks-and-targets.png", + ), + ( + "obstacles-and-scene", + "assets/art-spritesheet-slices/obstacles-and-scene.png", + ), + ( + "feedback-effects", + "assets/art-spritesheet-slices/feedback-effects.png", + ), +]; +const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, +]; +const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ + ("game/index.html", "game-entry", "text/html"), + ("game/style.css", "game-style", "text/css"), + ("game/game.js", "game-script", "text/javascript"), +]; + +fn direct_existing_game_sources_exist(root: &Path) -> bool { + DIRECT_CODEX_GAME_OUTPUTS + .iter() + .all(|(path, _, _)| root.join(path).is_file()) +} + +/// Art generation is an external, billable side effect. Existing direct +/// projects therefore stay in their same-thread code-edit/preview loop unless +/// the user explicitly asks for a new game or a visual regeneration. +fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { + let prompt = prompt.trim().to_lowercase(); + [ + "新游戏", + "新建", + "从头", + "重新生成", + "重做美术", + "换一套美术", + "重新生图", + "生成美术", + "做素材", + ] + .iter() + .any(|marker| prompt.contains(marker)) +} +const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台准备已登记的真实美术资源。优先使用规范图、场景背景和可用图集/切片;如果平台只返回完整透明图集或某些切片缺失,不要伪造切片,也不要因此停止生成。至少让一个真实平台素材进入实际渲染路径,并通过真实浏览器试玩检查画面和交互。不要用 emoji、纯色块或 CSS 图形冒充平台核心美术,也不要只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。收到客户端试玩证据后,必须读取实际文件并按证据继续修复;最终回复结构化列出检查文件、真实启动或试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题和剩余风险。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; +const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先准备并登记平台美术资源。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。规范图用于统一视觉风格;背景、完整图集和平台返回的切片可按实际可用性接入。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替真实平台素材。至少一个已登记平台图片必须被游戏源码引用并在页面中实际加载。"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectTaonierArtAssetIdentity { + resource_id: String, + asset_object_id: String, + canvas_project_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +#[derive(Debug)] +struct DirectCodexTurnFailure { + stage: DirectCodexFailureStage, + error: String, +} + +impl DirectCodexTurnFailure { + fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self { + Self { + stage, + error: error.into(), + } + } +} + +fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if private_external_editor_credentials_storage_preparation_failed(error) { + return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; + } + if private_external_editor_credentials_persistence_failed(error) { + return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"; + } + if error.contains("本机陶泥儿开发者 Key") { + return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"; + } + if normalized.contains("authentication-required") + || normalized.contains("unauthorized") + || normalized.contains("http 401") + { + return "登录态可能已失效,请重新登录陶泥儿后重试"; + } + if normalized.contains("permission-denied") || normalized.contains("http 403") { + return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; + } + if error.contains("项目正在被其他写操作占用") { + return "当前项目仍有写入正在结束,请稍后再次发送该需求"; + } + if error.contains("身份不唯一") + || error.contains("身份不匹配") + || error.contains("未找到身份完整的历史图集") + || error.contains("没有可见像素") + { + return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; + } + match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + } +} + +fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { + if private_external_editor_credentials_storage_preparation_failed(error) { + return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); + } + if private_external_editor_credentials_persistence_failed(error) { + return Some("本机开发者凭据已创建但未能安全保存"); + } + None +} + +fn direct_codex_failure_is_retryable(error: &str) -> bool { + ![ + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + ] + .iter() + .any(|marker| error.contains(marker)) +} + +fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { + let summary = direct_codex_failure_public_summary(&failure.error) + .map(str::to_string) + .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); + let summary = summary.split_whitespace().collect::>().join(" "); + let summary = if summary.trim().is_empty() { + "未提供可安全展示的详细原因".to_string() + } else { + summary + }; + let retryable = direct_codex_failure_is_retryable(&failure.error); + let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error); + let diagnostic = serde_json::json!({ + "schemaVersion": "direct-codex-diagnostic.v1", + "stage": failure.stage.id(), + "summary": summary, + "retryable": retryable, + "recoveryHint": recovery_hint, + "occurredAtUnixNanos": unix_timestamp_nanos().to_string(), + }); + let relative_path = format!( + ".agent/runtime/direct-codex-diagnostics/{}/failure.json", + unix_timestamp_nanos() + ); + let diagnostic_written = write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 失败诊断", + &diagnostic, + 8 * 1024, + ) + .is_ok(); + let diagnostics_suffix = if diagnostic_written { + "已保存脱敏项目诊断" + } else { + "未能保存项目诊断" + }; + format!( + "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}", + failure.stage.id(), + retryable, + diagnostic["summary"] + .as_str() + .unwrap_or("未提供可安全展示的详细原因"), + recovery_hint, + diagnostics_suffix, + ) +} + +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, + expected_kind: &str, + expected_generation_route: &str, + expected_generation_kind: &str, + expected_reference_resource_ids: &[&str], + expected_canvas_project_id: Option<&str>, +) -> Option { + let manifest = read_manifest(&root.join(".agent/manifest.json")).ok()?; + manifest.assets.iter().find_map(|asset| { + if asset.local_path != expected_path + || asset.kind != expected_kind + || !asset.media_type.starts_with("image/") + || asset.source.kind != GameCreationAppAssetSourceKind::Canvas + || asset.source.generation_route.as_deref() != Some(expected_generation_route) + || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) + || asset.source.reference_resource_ids.len() != expected_reference_resource_ids.len() + || !asset + .source + .reference_resource_ids + .iter() + .map(String::as_str) + .eq(expected_reference_resource_ids.iter().copied()) + || expected_canvas_project_id + .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected)) + { + return None; + } + let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; + if !asset_path.is_file() { + return None; + } + let bytes = std::fs::read(asset_path).ok()?; + let validated = validate_platform_art_png_bytes_with_limits( + &bytes, + &format!("陶泥儿平台素材 {}", asset.local_path), + ) + .ok()?; + if !validated.has_visible_pixels { + return None; + } + Some(DirectTaonierArtAssetIdentity { + resource_id: asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + asset_object_id: asset + .source + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + canvas_project_id: asset + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + }) + }) +} + +fn direct_taonier_art_base_is_valid(root: &Path) -> bool { + let Some(art_spec) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) else { + return false; + }; + let Some(background) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) else { + return false; + }; + let _ = (background, art_spec); + true +} + +fn direct_taonier_art_package_is_valid(root: &Path) -> bool { + let Some(art_spec) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) else { + return false; + }; + if !direct_taonier_art_base_is_valid(root) { + return false; + } + let Some(spritesheet) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) else { + return false; + }; + let _ = (spritesheet, art_spec); + true +} + +fn direct_asset_file_name(path: &str) -> &str { + Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(path) +} + +fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<&'static str> { + let sources = ["game/index.html", "game/style.css", "game/game.js"] + .iter() + .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) + .collect::>(); + let mut available_paths = Vec::new(); + if direct_taonier_art_base_is_valid(root) { + available_paths.push(DIRECT_CODEX_ART_SPEC_ASSET_PATH); + available_paths.push(DIRECT_CODEX_BACKGROUND_ASSET_PATH); + } + if direct_taonier_art_package_is_valid(root) { + available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + } + available_paths + .into_iter() + .filter(|path| { + sources.iter().any(|source| { + source.contains(path) || source.contains(direct_asset_file_name(path)) + }) + }) + .collect() +} + +fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { + !direct_game_sources_referenced_taonier_assets(root).is_empty() +} + +fn direct_normalized_project_asset_path(path: &str) -> Option { + let normalized = path.trim().replace('\\', "/"); + let normalized = normalized.split(['?', '#']).next().unwrap_or_default(); + let normalized = normalized.trim_start_matches('/'); + (!normalized.is_empty() && !normalized.contains("..")).then(|| normalized.to_string()) +} + +const DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX: &str = "[advisory] rendered-local-images:"; + +fn direct_browser_rendered_image_paths(viewport: &BrowserViewportValidationResult) -> Vec<&str> { + viewport + .diagnostics + .iter() + .filter_map(|diagnostic| { + diagnostic.strip_prefix(DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX) + }) + .flat_map(|paths| paths.split('|')) + .map(str::trim) + .filter(|path| !path.is_empty()) + .collect() +} + +fn direct_rendered_taonier_assets_in_viewport( + root: &Path, + viewport: &BrowserViewportValidationResult, +) -> Vec<&'static str> { + let referenced = direct_game_sources_referenced_taonier_assets(root); + referenced + .into_iter() + .filter(|asset_path| { + direct_browser_rendered_image_paths(viewport) + .iter() + .any(|rendered_path| { + direct_normalized_project_asset_path(rendered_path) + .as_deref() + .is_some_and(|rendered_path| { + rendered_path == *asset_path + || rendered_path.ends_with(&format!("/{asset_path}")) + }) + }) + }) + .collect() +} + +fn direct_browser_evidence_needs_art_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, +) -> bool { + let Some(evidence) = evidence else { + return false; + }; + evidence.passed + && evidence + .viewport_results + .iter() + .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty()) +} + +/// The only output-shape proof the direct Runtime owns. It deliberately does +/// not prescribe a spritesheet layout, slice names, `drawImage` count, or a +/// particular canvas implementation: those are quality questions for the +/// same Codex thread after it has seen real browser evidence. +fn direct_game_output_completion_error(root: &Path) -> Option { + if !root.join("game/index.html").is_file() { + return Some("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + } + if !direct_game_sources_reference_taonier_art_package(root) { + return Some( + "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" + .to_string(), + ); + } + None +} + +fn direct_acceptance_needs_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, + completion_error: Option<&str>, +) -> bool { + completion_error.is_some() + || evidence.is_none_or(|result| !result.passed) + || direct_browser_evidence_needs_art_repair(root, evidence) +} + +fn direct_recoverable_spritesheet_resource<'a>( + resources: &'a [serde_json::Value], + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result, String> { + let mut candidates = resources.iter().filter(|resource| { + let kind = json_string_field(resource, "assetKind") + .or_else(|| json_string_field(resource, "kind")); + let source_resource_id = json_string_field(resource, "sourceResourceId"); + let referenced_spec_ids = resource + .pointer("/generationInputs/references") + .and_then(serde_json::Value::as_array) + .map(|references| { + references + .iter() + .filter_map(|reference| { + json_string_field(reference, "refId") + .or_else(|| json_string_field(reference, "resourceId")) + .or_else(|| json_string_field(reference, "id")) + }) + .collect::>() + }); + let bound_to_current_spec = match source_resource_id { + Some(source_resource_id) => source_resource_id == art_spec.resource_id, + // The platform's source-only fallback preserves the generated + // spritesheet as the primary resource and therefore has no + // sourceResourceId. Its immutable request reference is an + // equivalent identity only when it contains exactly the current + // spec and no other resource. + None => referenced_spec_ids.as_ref().is_some_and(|references| { + references.len() == 1 && references[0] == art_spec.resource_id + }), + }; + kind.as_deref() == Some("icon-spritesheet") + && json_string_field(resource, "projectId").as_deref() + == Some(art_spec.canvas_project_id.as_str()) + && bound_to_current_spec + }); + let Some(resource) = candidates.next() else { + return Ok(None); + }; + if candidates.next().is_some() { + return Err("陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复".to_string()); + } + if json_string_field(resource, "generationRoute") + .or_else(|| json_string_field(resource, "generation_route")) + .is_some_and(|route| route != "/api/external/v1/editor/icon-spritesheets/generations") + || json_string_field(resource, "generationKind") + .or_else(|| json_string_field(resource, "generation_kind")) + .is_some_and(|kind| kind != "icon-spritesheet") + { + return Err("匹配到的陶泥儿图集资源生成身份不匹配,已拒绝恢复".to_string()); + } + Ok(Some(resource)) +} + +async fn recover_direct_taonier_spritesheet_read_only_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; + let route = resolve_platform_editor_api_route(&format!( + "/api/external/v1/editor/projects/{}", + percent_encode_query_component(&art_spec.canvas_project_id) + )); + let response = client + .get(format!("{api_base_url}{route}")) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析陶泥儿画布资源失败:{error}"))?; + let project = payload + .get("project") + .or_else(|| payload.pointer("/data/project")) + .ok_or_else(|| "陶泥儿画布资源响应缺少 project".to_string())?; + let resources = project + .get("resources") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?; + let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else { + return Ok(false); + }; + let resource_id = json_string_field(resource, "resourceId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 resourceId".to_string())?; + let asset_object_id = json_string_field(resource, "assetObjectId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 assetObjectId".to_string())?; + let task_id = json_string_field(resource, "taskId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 taskId".to_string())?; + let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) + .await? + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "恢复的陶泥儿 art-spritesheet", + )?; + if !validated.has_visible_pixels { + return Err("恢复的陶泥儿 art-spritesheet 没有可见像素".to_string()); + } + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.art-spritesheet-recovery", + )?; + let current_art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + Some(&art_spec.canvas_project_id), + ) + .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; + if current_art_spec != *art_spec { + return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string()); + } + let output = resolve_local_project_path(root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH)?; + if output.exists() { + return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string()); + } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建陶泥儿图集目录失败:{error}"))?; + } + let mut output_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output) + .map_err(|error| format!("创建恢复的陶泥儿图集文件失败:{error}"))?; + output_file + .write_all(&download.bytes) + .and_then(|_| output_file.sync_all()) + .map_err(|error| { + let _ = std::fs::remove_file(&output); + format!("写入恢复的陶泥儿图集失败:{error}") + })?; + drop(output_file); + let register = register_local_asset_at( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "image/png", + "platform-art-recovery", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(art_spec.canvas_project_id.clone()), + resource_id: Some(resource_id), + asset_object_id: Some(asset_object_id), + task_id: Some(task_id), + prompt: json_string_field(resource, "actualPrompt") + .or_else(|| json_string_field(resource, "prompt")), + model: json_string_field(resource, "model"), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec![art_spec.resource_id.clone()], + }, + ); + if let Err(error) = register { + let _ = std::fs::remove_file(&output); + return Err(error); + } + emit_direct_game_creator_progress( + root, + "art.spritesheet.recovered", + "已从陶泥儿画布只读恢复核心图集", + ); + Ok(true) +} + +fn direct_spritesheet_source_preserved_failure(output_path: &str, error: &str) -> bool { + output_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH + && error + .trim_start() + .starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX) +} + +fn clear_direct_taonier_generation_ledger_after_source_recovery( + root: &Path, + runtime_context: &PlatformArtGenerationRuntimeContext, +) -> Result<(), String> { + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + ) + .map_err(|error| format!("已恢复平台源图,但清理已完成生成账本失败:{error}")) +} + +/// A postprocess fallback has already completed the paid platform generation +/// and preserved its source resource. The direct Runtime may only make a +/// bounded, read-only attempt to recover that exact resource; it must never +/// turn this branch into another generation submission. +async fn recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + const MAX_READ_ONLY_ATTEMPTS: usize = 3; + + for attempt in 0..MAX_READ_ONLY_ATTEMPTS { + if recover_direct_taonier_spritesheet_read_only_at(root, art_spec).await? { + return Ok(true); + } + if attempt + 1 < MAX_READ_ONLY_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + } + Ok(false) +} + +async fn generate_direct_taonier_art_asset_at( + root: &Path, + prompt: &str, + output_path: &str, + aspect_ratio: &str, + asset_kind: &str, + asset_label: &str, + require_slices: bool, +) -> Result<(), String> { + let options = PlatformArtAssetGenerationOptions { + output_path: Some(output_path.to_string()), + aspect_ratio: aspect_ratio.to_string(), + image_size: "1K".to_string(), + asset_kind: asset_kind.to_string(), + asset_label: asset_label.to_string(), + replace_existing: root.join(output_path).is_file(), + }; + let runtime_context = + direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; + let generated = match generate_platform_art_asset_with_runtime_options_at( + root, + prompt, + &[], + &options, + require_slices, + &runtime_context, + ) + .await + { + Ok(generated) => generated, + Err(error) if direct_spritesheet_source_preserved_failure(output_path, &error) => { + let art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) + .ok_or_else(|| { + "陶泥儿核心图集透明后处理失败,但当前规范图不再满足来源、下载或解码校验,已拒绝恢复源图" + .to_string() + })?; + let recovered = recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root, &art_spec, + ) + .await + .map_err(|recovery_error| { + format!( + "陶泥儿核心图集透明后处理失败后无法安全恢复已保留源图:{recovery_error};原始结果:{error}" + ) + })?; + if !recovered { + return Err(format!( + "陶泥儿核心图集透明后处理失败,平台已保留源图但在有界只读查询内未找到可安全恢复的唯一资源:{error}" + )); + } + clear_direct_taonier_generation_ledger_after_source_recovery(root, &runtime_context)?; + emit_direct_game_creator_progress( + root, + "art.spritesheet.source-recovered", + "透明后处理未完成,已安全恢复陶泥儿保留的核心源图", + ); + return Ok(()); + } + Err(error) => { + return Err(format!( + "陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}" + )); + } + }; + if generated.asset.local_path != output_path { + return Err(format!( + "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" + )); + } + Ok(()) +} + +async fn ensure_direct_taonier_art_package_at( + root: &Path, + prompt: &str, +) -> Result, String> { + emit_direct_game_creator_progress(root, "art.prepare", "正在检查陶泥儿美术包"); + if direct_taonier_art_package_is_valid(root) { + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + let existing_art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ); + let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) + .is_some() + }); + let art_spec = match existing_art_spec { + Some(identity) => identity, + None => { + emit_direct_game_creator_progress(root, "art.spec", "正在生成统一视觉规范图"); + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "1:1", + "icon-spec", + "陶泥儿首版游戏视觉规范图", + false, + ) + .await?; + emit_direct_game_creator_progress(root, "art.spec.ready", "统一视觉规范图已生成"); + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) + .ok_or_else(|| "陶泥儿规范图生成后未形成可用平台合同,已终止代码生成".to_string())? + } + }; + if direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) + .is_none() + { + emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图"); + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "16:9", + "game-background", + "陶泥儿首版游戏场景背景", + false, + ) + .await?; + emit_direct_game_creator_progress(root, "art.background.ready", "游戏场景背景图已生成"); + } + if direct_taonier_art_package_is_valid(root) { + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + if recover_direct_taonier_spritesheet_read_only_at(root, &art_spec).await? { + if direct_taonier_art_package_is_valid(root) { + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + } + if existing_art_spec_and_background { + if !direct_taonier_art_base_is_valid(root) { + return Err("当前项目已有的陶泥儿规范图或背景图不再满足可下载、可解码和来源一致性要求,已终止代码生成".to_string()); + } + emit_direct_game_creator_progress( + root, + "art.ready", + "核心图集未能安全恢复,已使用现有陶泥儿规范图和背景图继续创作", + ); + return Ok(vec![ + DIRECT_CODEX_ART_SPEC_ASSET_PATH.to_string(), + DIRECT_CODEX_BACKGROUND_ASSET_PATH.to_string(), + ]); + } + emit_direct_game_creator_progress( + root, + "art.spritesheet", + "正在生成核心图集并切分四类运行时素材", + ); + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "1:1", + "art-spritesheet", + "陶泥儿首版核心游戏美术图集", + false, + ) + .await?; + if !direct_taonier_art_package_is_valid(root) { + return Err("陶泥儿美术包缺少可用的已登记主图,已终止代码生成".to_string()); + } + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()) +} + +fn direct_codex_generated_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("code-prototype".to_string()), + prompt: None, + model: None, + generation_route: Some("direct-codex".to_string()), + generation_kind: Some("project-file".to_string()), + reference_resource_ids: Vec::new(), + } +} + +fn direct_codex_output_fingerprint(root: &Path) -> String { + let mut hasher = Sha256::new(); + for (local_path, _, _) in DIRECT_CODEX_GAME_OUTPUTS { + hasher.update(local_path.as_bytes()); + hasher.update([0]); + match std::fs::read(root.join(local_path)) { + Ok(bytes) => { + hasher.update([1]); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Err(_) => hasher.update([0]), + } + } + format!("{:x}", hasher.finalize()) +} + +fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root) + .map(|value| value.revision) + .unwrap_or(0); + resolve_local_project_path( + root, + &format!(".agent/runtime/direct-codex-browser-validation/{revision}/attempt-{attempt}"), + ) +} + +fn direct_browser_viewport_evidence( + root: &Path, + result: &BrowserViewportValidationResult, +) -> String { + let screenshot = result + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")) + .unwrap_or_else(|| "evidence-screenshot.png".to_string()); + let canvas_non_empty = result + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + let fatal_requests = result + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(); + format!( + "{}: passed={}, readyState={}, canvas={}/{} nonEmpty, consoleErrors={}, exceptions={}, fatalRequests={}, screenshot={}", + match result.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + result.passed, + result.ready_state, + canvas_non_empty, + result.canvases.len(), + result.console_errors.len(), + result.exceptions.len(), + fatal_requests, + screenshot, + ) +} + +fn direct_browser_evidence_prompt( + root: &Path, + result: Option<&BrowserValidationResult>, + attempt: usize, + output_changed: bool, + completion_error: Option<&str>, +) -> String { + let completion_status = completion_error + .map(|error| { + format!( + "未通过:{}。这是客户端最低完成证明,不是可忽略的建议;请直接修改真实 `game/` 文件后再回复。", + redact_agent_runtime_error(root, error, 320) + ) + }) + .unwrap_or_else(|| { + "通过:已找到 `game/index.html`,且源码至少引用了一份已登记的陶泥儿平台图片。" + .to_string() + }); + let (browser_status, viewport_lines, details, advisory) = match result { + Some(result) => { + let viewport_lines = result + .viewport_results + .iter() + .map(|viewport| direct_browser_viewport_evidence(root, viewport)) + .collect::>(); + let art_observations = result + .viewport_results + .iter() + .map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let rendered = direct_rendered_taonier_assets_in_viewport(root, viewport); + if rendered.is_empty() { + format!( + "{viewport_name}: 未在 Canvas/WebGL 渲染调用中观察到已登记陶泥儿图片;旁侧缩略图不能证明素材进入核心玩法,源码引用也不能代替运行时证据" + ) + } else { + format!( + "{viewport_name}: Canvas/WebGL 渲染调用观察到陶泥儿图片 {}", + rendered.join("、") + ) + } + }) + .collect::>(); + let advisory = result + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 240)) + .collect::>(); + let details = result + .viewport_results + .iter() + .flat_map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let mut details = Vec::new(); + details.extend( + viewport + .diagnostics + .iter() + .filter(|diagnostic| !diagnostic.starts_with("[advisory]")) + .map(|diagnostic| { + format!( + "{viewport_name} diagnostic: {}", + redact_agent_runtime_error(root, diagnostic, 320) + ) + }), + ); + details.extend(viewport.console_errors.iter().take(4).map(|message| { + format!( + "{viewport_name} console.error: {}", + redact_agent_runtime_error(root, &message.text, 320) + ) + })); + details.extend(viewport.exceptions.iter().take(4).map(|exception| { + format!( + "{viewport_name} exception: {}", + redact_agent_runtime_error(root, &exception.text, 320) + ) + })); + details.extend( + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .take(4) + .map(|request| { + format!( + "{viewport_name} fatal request: {} {} ({})", + request.method, + redact_agent_runtime_error(root, &request.url, 320), + redact_agent_runtime_error(root, &request.error_text, 220), + ) + }), + ); + details + }) + .collect::>(); + ( + format!("整体 passed={}", result.passed), + viewport_lines, + { + let mut details = details; + details.extend(art_observations); + details + }, + advisory, + ) + } + None => ( + "本次未启动 Chromium:最低完成证明尚未满足。不得把这当作试玩通过;修复后客户端才会启动真实 desktop/mobile 试玩。".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + }; + format!( + "请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n请读取当前工作区的实际 `game/` 文件和可用 `assets/`,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。", + viewport_lines.join("\n"), + if details.is_empty() { + "无额外硬失败详情".to_string() + } else { + details.join("\n") + }, + if advisory.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + advisory.join(";") + }, + ) +} + +async fn run_direct_browser_evidence_at( + root: &Path, + attempt: usize, +) -> Result { + let evidence_root = direct_browser_evidence_root(root, attempt)?; + let (preview, stop_sender) = start_local_game_preview_for_project(root)?; + let validation = validate_local_preview_in_browser_with_interaction( + BrowserValidationInput { + url: preview.url, + viewports: vec![ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Mobile, + ], + expected_text: Vec::new(), + settle_ms: 800, + fail_on_console_error: true, + playtest_scenario: None, + evidence_root, + }, + true, + ) + .await; + let _ = stop_sender.send(()); + validation +} + +fn write_direct_browser_acceptance_summary( + root: &Path, + evidence: &BrowserValidationResult, + accepted: bool, + attempts: usize, +) -> Result<(), String> { + let evidence_root = evidence + .evidence + .root + .strip_prefix(root) + .map_err(|_| "直连 Codex 浏览器证据目录不在当前项目内".to_string())?; + let evidence_directory = evidence_root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| "直连 Codex 浏览器证据目录缺少版本目录".to_string())?; + let relative_path = evidence_directory + .join("acceptance-summary.json") + .to_string_lossy() + .replace('\\', "/"); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "passed": viewport.passed, + "readyState": viewport.ready_state, + "nonEmptyCanvasCount": viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(), + "canvasCount": viewport.canvases.len(), + "consoleErrorCount": viewport.console_errors.len(), + "exceptionCount": viewport.exceptions.len(), + "fatalRequestCount": viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + "screenshot": viewport + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")), + }) + }) + .collect::>(); + let diagnostics = evidence + .diagnostics + .iter() + .take(12) + .map(|detail| redact_agent_runtime_project_paths(root, detail, 320)) + .collect::>(); + let rendered_assets_by_viewport = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "assets": direct_rendered_taonier_assets_in_viewport(root, viewport), + }) + }) + .collect::>(); + let source_report = evidence + .evidence + .report_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")); + let summary = serde_json::json!({ + "schemaVersion": "direct-codex-browser-acceptance.v1", + "accepted": accepted, + "attempts": attempts, + "browserPassed": evidence.passed, + "viewports": viewports, + "diagnostics": diagnostics, + "renderedTaonierAssetsByViewport": rendered_assets_by_viewport, + "sourceReport": source_report, + }); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 浏览器验收摘要", + &summary, + 32 * 1024, + ) +} + +fn render_direct_browser_acceptance_report( + root: &Path, + evidence: &BrowserValidationResult, +) -> String { + let referenced_assets = direct_game_sources_referenced_taonier_assets(root); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let non_empty_canvas_count = viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + format!( + "{name}: readyState={},非空 Canvas {non_empty_canvas_count}/{},控制台错误 {},异常 {},致命请求 {}", + viewport.ready_state, + viewport.canvases.len(), + viewport.console_errors.len(), + viewport.exceptions.len(), + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + ) + }) + .collect::>(); + let interaction = evidence + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 180)) + .collect::>(); + let rendered_assets = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let assets = direct_rendered_taonier_assets_in_viewport(root, viewport); + if assets.is_empty() { + format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + } else { + format!("{name}: {}", assets.join("、")) + } + }) + .collect::>(); + format!( + "### 客户端真实试玩证据\n\n- 检查文件:`game/index.html`、`game/style.css`、`game/game.js` 及已登记陶泥儿图片。\n- 试玩动作:受限本地预览 + 真实 Chromium desktop/mobile 双视口;截图和完整报告已保存到项目 `.agent/runtime/direct-codex-browser-validation/`。\n- 双视口观察:{}。\n- 平台素材来源:已登记且可解码的陶泥儿图片;游戏源码引用:{}。\n- 平台素材运行时观察:{}。\n- 交互探针:{}。\n- 边界说明:客户端只证明来源、文件和浏览器运行基础;画面质量与玩法判断已由同一 Codex thread 依据上述证据复核。", + viewports.join(";"), + if referenced_assets.is_empty() { + "无(此状态不应被登记为完成)".to_string() + } else { + referenced_assets.join("、") + }, + rendered_assets.join(";"), + if interaction.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + interaction.join(";") + }, + ) +} + +fn sync_direct_codex_project_outputs_at( + root: &Path, + previous_output_fingerprint: Option<&str>, +) -> Result<(), String> { + if !direct_taonier_art_base_is_valid(root) { + return Err( + "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成" + .to_string(), + ); + } + if let Some(error) = direct_game_output_completion_error(root) { + return Err(error); + } + let current_output_fingerprint = direct_codex_output_fingerprint(root); + let outputs_changed = previous_output_fingerprint + .map(|previous| previous != current_output_fingerprint) + .unwrap_or(true); + let manifest_before = read_manifest(&root.join(".agent/manifest.json"))?; + let expected_source = direct_codex_generated_source(); + let manifest_requires_sync = + DIRECT_CODEX_GAME_OUTPUTS + .iter() + .any(|(local_path, kind, media_type)| { + root.join(local_path).is_file() + && !manifest_before.assets.iter().any(|asset| { + asset.local_path == *local_path + && asset.kind == *kind + && asset.media_type == *media_type + && asset.source == expected_source + }) + }) + || manifest_before + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed) + || manifest_before.versions.is_empty(); + let mut registered = 0_usize; + for (local_path, kind, media_type) in DIRECT_CODEX_GAME_OUTPUTS { + if !root.join(local_path).is_file() { + continue; + } + register_local_asset_at( + root, + local_path, + kind, + media_type, + "direct-codex", + direct_codex_generated_source(), + )?; + registered += 1; + } + if registered == 0 { + return Err("Codex 返回后没有可登记的游戏文件".to_string()); + } + update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?; + if outputs_changed || manifest_requires_sync { + 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)?; + } + Ok(()) +} + +fn read_context_file_with_limit(root: &Path, relative: &str, max_bytes: usize) -> Option { + let path = root.join(relative); + let bytes = std::fs::read(path).ok()?; + let bounded = &bytes[..bytes.len().min(max_bytes)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + (!text.trim().is_empty()).then_some(text) +} + +fn read_context_file(root: &Path, relative: &str) -> Option { + read_context_file_with_limit(root, relative, MAX_CONTEXT_FILE_BYTES) +} + +fn bundled_context_text(text: &str) -> String { + let bounded = text + .chars() + .take(MAX_CONTEXT_FILE_BYTES) + .collect::(); + sanitize_direct_context(&bounded) +} + +fn sanitize_direct_context(text: &str) -> String { + let mut output = String::new(); + for line in text.lines() { + let lower = line.to_ascii_lowercase(); + if [ + "api_key", + "apikey", + "access_token", + "bearer ", + "auth.json", + ".env", + "cookie", + "c:\\users\\", + "\\users\\", + "/users/", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + output.push_str("[已省略疑似凭据或宿主私密路径的内容]\n"); + continue; + } + let line = line.chars().take(1_000).collect::(); + output.push_str(&line); + output.push('\n'); + } + output.chars().take(MAX_CONTEXT_FILE_BYTES).collect() +} + +fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> { + let directory = root.join(relative_dir); + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md")) + .collect::>(); + paths.sort(); + paths + .into_iter() + .take(MAX_CONTEXT_FILES_PER_SOURCE) + .filter_map(|path| { + let bytes = std::fs::read(&path).ok()?; + let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + let label = path + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/"))?; + (!text.trim().is_empty()).then_some((label, text)) + }) + .collect() +} + +fn read_skill_files(root: &Path) -> Vec<(String, String)> { + let mut skills = Vec::new(); + for relative_dir in [".codex/skills", ".agents/skills", ".hermes/skills"] { + let directory = root.join(relative_dir); + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + let Ok(bytes) = std::fs::read(&skill_path) else { + continue; + }; + let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + let Some(label) = skill_path + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/")) + else { + continue; + }; + if !text.trim().is_empty() { + skills.push((label, text)); + } + } + } + skills.sort_by(|left, right| left.0.cmp(&right.0)); + skills.truncate(MAX_CONTEXT_FILES_PER_SOURCE); + skills +} + +pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> String { + let mut sections = vec![ + "你是 Genarrative 的唯一执行智能体。用户聊天内容会直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。你负责理解需求、检查工程、修改工作区、运行必要验证,并用简洁中文报告真实结果。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), + "提示词与技能:本次系统上下文只包含当前工作区中有界、已脱敏的 prompts 与 SKILL.md 摘要。技能是执行约束,不是新的 Agent;按需读取技能引用的具体文件,但不要启动 Supervisor、child、harness 或外部编排。".to_string(), + format!("--- bundled skill: direct-game-creation ---\n{DIRECT_CODEX_BUILTIN_SKILL}"), + DIRECT_TAONIER_ART_WORKFLOW.to_string(), + ]; + sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 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) + { + sections.push(format!( + "\n--- current project file: {relative} ---\n{text}" + )); + } + } + for relative in [ + "AGENTS.md", + "README.md", + "CONTEXT.md", + "docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md", + "docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md", + ] { + if let Some(text) = read_context_file(root, relative) { + sections.push(format!("\n--- {relative} ---\n{text}")); + } + } + for (label, text) in [ + ("repository/AGENTS.md", BUNDLED_REPOSITORY_AGENTS), + ( + "repository/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md", + BUNDLED_AGC_IMPLEMENTATION_PLAN, + ), + ] { + let text = bundled_context_text(text); + if !text.trim().is_empty() { + sections.push(format!("\n--- bundled context: {label} ---\n{text}")); + } + } + for (label, text) in read_markdown_files(root, ".codex/prompts") + .into_iter() + .chain(read_markdown_files(root, "prompts")) + { + sections.push(format!("\n--- prompt: {label} ---\n{text}")); + } + for (label, text) in read_skill_files(root) { + sections.push(format!("\n--- skill: {label} ---\n{text}")); + } + sections.push("\n工程执行要求:优先复用现有结构;修改后运行与改动相关的测试、typecheck、编码检查和 git diff --check。需要生成游戏时,直接在当前工作区完成代码、资源、运行和验证闭环;不要创建新的 Supervisor/harness。".to_string()); + sections + .join("\n") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect() +} + +pub(crate) async fn run_direct_game_creator_turn_at( + root: &Path, + prompt: &str, +) -> Result { + if !root.is_absolute() || !root.is_dir() { + return Err("当前项目目录不存在或不是绝对路径".to_string()); + } + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作"); + match run_direct_game_creator_turn_inner(root, prompt).await { + Ok(reply) => Ok(reply), + Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), + } +} + +async fn run_direct_game_creator_turn_inner( + root: &Path, + prompt: &str, +) -> Result { + if direct_existing_game_sources_exist(root) + && !direct_prompt_requests_fresh_art_generation(prompt) + { + emit_direct_game_creator_progress( + root, + "request.edit-existing-game", + "已识别为已有游戏的继续编辑,正在直接修改并试玩", + ); + return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, false) + .await; + } + if direct_taonier_art_base_is_valid(root) { + return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true) + .await; + } + let external_editor_credentials = ensure_private_external_editor_api_credentials() + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; + with_external_editor_api_credentials(external_editor_credentials, async { + run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true).await + }) + .await +} + +async fn run_direct_game_creator_turn_with_private_editor_credentials( + root: &Path, + prompt: &str, + prepare_art: bool, +) -> Result { + if prepare_art { + ensure_direct_taonier_art_package_at(root, prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; + } + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let mut system_prompt = build_direct_codex_system_prompt(root); + if prepare_art { + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请读取当前 `assets/` 与 `game/` 文件,选择实际存在且适合玩法的素材接入;不要假设四切片一定存在,也不要伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;你必须依据证据继续修复,并在最终回复中给出检查文件、试玩动作、双视口观察、平台素材实际使用、已修复问题和剩余风险。"); + emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); + } else { + system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 `game/` 和已登记 `assets/`,按用户需求最小修改;随后通过真实 desktop/mobile 浏览器试玩验证修改。客户端会在回合后启动真实浏览器并把结构化证据回灌同一会话。"); + emit_direct_game_creator_progress(root, "codex.start", "正在修改已有游戏并进行试玩"); + } + let _initial_reply = + direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let mut completion_error = direct_game_output_completion_error(root); + let mut evidence_attempts = 0_usize; + let mut evidence: Option = None; + let mut browser_checked_output_fingerprint: Option = None; + if completion_error.is_none() { + emit_direct_game_creator_progress( + root, + "codex.validate", + "游戏代码已生成,正在通过真实浏览器试玩", + ); + evidence_attempts = 1; + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); + } else { + emit_direct_game_creator_progress(root, "codex.validate", "正在检查游戏入口与平台素材引用"); + } + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 1, + initial_output_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); + let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let repaired_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) + || evidence.as_ref().is_none_or(|result| !result.passed)) + { + evidence_attempts += 1; + emit_direct_game_creator_progress(root, "codex.revalidate", "正在复核自主修改后的游戏"); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); + } else if completion_error.is_some() { + evidence = None; + } + if direct_acceptance_needs_repair(root, evidence.as_ref(), completion_error.as_deref()) { + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 2, + repaired_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress( + root, + "codex.repair", + "试玩未通过,正在进行最后一次自主修复", + ); + reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let final_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) + { + evidence_attempts += 1; + emit_direct_game_creator_progress( + root, + "codex.final-validate", + "正在进行最终真实浏览器试玩", + ); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + error, + ) + })?, + ); + } else if completion_error.is_some() { + evidence = None; + } + } + if let Some(completion_error) = completion_error { + if let Some(evidence) = evidence.as_ref() { + write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::VersionRegistration, + format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), + )); + } + let Some(evidence) = evidence else { + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", + )); + }; + if !evidence.passed { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + format!( + "Codex 自主试玩未通过,未登记完成版本:{}", + evidence + .diagnostics + .iter() + .take(4) + .cloned() + .collect::>() + .join(";") + ), + )); + } + if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" + .to_string(), + )); + } + emit_direct_game_creator_progress( + root, + "codex.ready", + "游戏已通过真实浏览器试玩,正在登记项目版本", + ); + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; + write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; + emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); + Ok(format!( + "{}\n\n{}", + reply.trim(), + render_direct_browser_acceptance_report(root, &evidence) + )) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, +) -> Result { + run_direct_game_creator_turn_at(Path::new(project_path.trim()), &prompt).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn system_prompt_is_bounded_and_declares_direct_runtime() { + let prompt = build_direct_codex_system_prompt(Path::new(".")); + assert!(prompt.contains("唯一执行智能体")); + assert!(prompt.contains("不要等待 Supervisor")); + assert!(prompt.contains("提示词与技能")); + } + + #[test] + fn system_prompt_loads_project_prompts_and_skills_without_secrets() { + let root = tempfile::tempdir().expect("temp dir"); + let prompt_dir = root.path().join(".codex/prompts"); + let skill_dir = root.path().join(".codex/skills/demo"); + std::fs::create_dir_all(&prompt_dir).expect("prompt dir"); + std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::fs::write( + prompt_dir.join("game.md"), + "做一个 canvas 游戏\napi_key=secret", + ) + .expect("prompt"); + std::fs::write( + skill_dir.join("SKILL.md"), + "技能要求:先验证入口\nBearer secret", + ) + .expect("skill"); + let prompt = build_direct_codex_system_prompt(root.path()); + assert!(prompt.contains("prompt: .codex/prompts/game.md")); + assert!(prompt.contains("skill: .codex/skills/demo/SKILL.md")); + assert!(prompt.contains("做一个 canvas 游戏")); + assert!(prompt.contains("bundled context: repository/AGENTS.md")); + assert!(!prompt.contains("secret")); + } + + #[test] + fn system_prompt_includes_current_game_files_for_safe_existing_project_edits() { + let root = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir_all(root.path().join("game")).expect("game dir"); + std::fs::write( + root.path().join("game/game.js"), + "const directLayoutMarker = 'hud-board-gap';\napi_key=secret", + ) + .expect("game script"); + + let prompt = build_direct_codex_system_prompt(root.path()); + + assert!(prompt.contains("current project file: game/game.js")); + assert!(prompt.contains("directLayoutMarker")); + assert!(!prompt.contains("api_key=secret")); + } + + #[test] + fn system_prompt_has_a_total_size_bound() { + let root = tempfile::tempdir().expect("temp dir"); + let prompt_dir = root.path().join("prompts"); + std::fs::create_dir_all(&prompt_dir).expect("prompt dir"); + std::fs::write(prompt_dir.join("large.md"), "x".repeat(128 * 1024)).expect("large prompt"); + let prompt = build_direct_codex_system_prompt(root.path()); + assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS); + } + + #[test] + fn direct_prompt_declares_taonier_art_workflow_and_project_structure() { + let prompt = build_direct_codex_system_prompt(Path::new(".")); + assert!(prompt.contains("陶泥儿游戏创作工作流")); + assert!(prompt.contains("`assets/`")); + assert!(prompt.contains("规范图用于统一视觉风格")); + assert!(prompt.contains("完整图集和平台返回的切片可按实际可用性接入")); + assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`")); + assert!(prompt.contains("最终回复结构化列出检查文件")); + assert!(prompt.contains("desktop/mobile 观察")); + } + + #[test] + fn existing_game_edits_do_not_request_a_fresh_art_generation_by_default() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-edit-intent", "继续编辑") + .expect("init project"); + std::fs::write(root.path().join("game/index.html"), "").expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {};").expect("style"); + std::fs::write(root.path().join("game/game.js"), "console.log('edit');").expect("script"); + + assert!(direct_existing_game_sources_exist(root.path())); + for prompt in [ + "把棋盘上移一点", + "修复闪烁", + "继续优化交互", + "增加一个重开按钮", + ] { + assert!( + !direct_prompt_requests_fresh_art_generation(prompt), + "{prompt} must stay in the existing-game edit loop" + ); + } + } + + #[test] + fn explicit_new_game_or_art_request_enters_fresh_art_generation_path() { + for prompt in [ + "新建一个游戏", + "从头做一个三消", + "重做美术", + "重新生图", + "做素材", + ] { + assert!( + direct_prompt_requests_fresh_art_generation(prompt), + "{prompt} must explicitly opt into art generation" + ); + } + } + + #[test] + fn missing_platform_art_reference_becomes_a_same_thread_repair_prompt() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-repair-project", "直连整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "document.querySelector('#game').getContext('2d').fillRect(0, 0, 1, 1);", + ) + .expect("script"); + + let completion_error = direct_game_output_completion_error(root.path()) + .expect("missing platform image reference must require repair"); + let prompt = + direct_browser_evidence_prompt(root.path(), None, 1, true, Some(&completion_error)); + + assert!(direct_acceptance_needs_repair( + root.path(), + None, + Some(&completion_error) + )); + assert!(prompt.contains("同一 Codex 会话")); + assert!(prompt.contains("客户端最低完成证明")); + assert!(prompt.contains("未在源码中引用任何已登记的陶泥儿平台图片")); + assert!(prompt.contains("本次未启动 Chromium")); + assert!(prompt.contains("直接修改真实 `game/` 文件")); + } + + fn direct_browser_evidence_fixture( + root: &Path, + viewport_diagnostics: &[(&str, &[&str])], + ) -> BrowserValidationResult { + let viewport_results = viewport_diagnostics + .iter() + .map(|(viewport, diagnostics)| BrowserViewportValidationResult { + viewport: if *viewport == "mobile" { + BrowserValidationViewport::Mobile + } else { + BrowserValidationViewport::Desktop + }, + width: if *viewport == "mobile" { 390 } else { 1280 }, + height: if *viewport == "mobile" { 844 } else { 720 }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.join(format!("{viewport}.png")), + passed: true, + diagnostics: diagnostics + .iter() + .map(|value| (*value).to_string()) + .collect(), + }) + .collect(); + BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results, + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence", + ), + report_path: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence/validation.json", + ), + }, + completed_at_unix_ms: 1, + } + } + + #[test] + fn rendered_taonier_asset_observation_satisfies_both_viewports_and_is_reported() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-rendered-art", "运行时素材观察") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = format!( + "{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png?cache=fixture" + ); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[ + ("desktop", &[rendered.as_str()]), + ("mobile", &[rendered.as_str()]), + ], + ); + + assert!(!direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let report = render_direct_browser_acceptance_report(root.path(), &evidence); + assert!(report.contains("平台素材运行时观察"), "{report}"); + assert!( + report.contains("desktop: assets/art-spritesheet.png"), + "{report}" + ); + assert!( + report.contains("mobile: assets/art-spritesheet.png"), + "{report}" + ); + + write_direct_browser_acceptance_summary(root.path(), &evidence, true, 1) + .expect("write acceptance summary"); + let summary = std::fs::read_to_string(root.path().join( + ".agent/runtime/direct-codex-browser-validation/fixture/acceptance-summary.json", + )) + .expect("read acceptance summary"); + assert!(summary.contains("renderedTaonierAssetsByViewport")); + assert_eq!(summary.matches("assets/art-spritesheet.png").count(), 2); + } + + #[test] + fn source_reference_without_canvas_or_webgl_observation_requests_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-side-thumbnail", "旁侧缩略图整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let evidence = + direct_browser_evidence_fixture(root.path(), &[("desktop", &[]), ("mobile", &[])]); + + assert!(direct_game_output_completion_error(root.path()).is_none()); + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 1, true, None); + assert!( + prompt.contains("旁侧缩略图不能证明素材进入核心玩法"), + "{prompt}" + ); + assert!( + prompt.contains("请读取当前工作区的实际 `game/` 文件"), + "{prompt}" + ); + } + + #[test] + fn one_viewport_without_rendered_platform_art_still_requests_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-mobile-art-gap", "移动端素材整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = + format!("{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png"); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[("desktop", &[rendered.as_str()]), ("mobile", &[])], + ); + + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + } + + #[test] + fn browser_diagnostics_are_redacted_before_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + let browser_error = format!( + "Uncaught ReferenceError at {}?token=secret", + root.path().join("game/game.js").display() + ); + let evidence = BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: false, + viewport_results: vec![BrowserViewportValidationResult { + viewport: BrowserValidationViewport::Desktop, + width: 1280, + height: 720, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: vec![BrowserException { + text: browser_error, + source_url: None, + line_number: 1, + column_number: 1, + }], + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.path().join("desktop.png"), + passed: false, + diagnostics: vec!["canvas is empty".to_string()], + }], + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.path().join("evidence"), + report_path: root.path().join("evidence/validation.json"), + }, + completed_at_unix_ms: 1, + }; + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 2, false, None); + + assert!(prompt.contains("desktop exception:")); + assert!(prompt.contains("同一 Codex 会话")); + assert!(!prompt.contains("secret"), "{prompt}"); + assert!( + !prompt.contains(&root.path().display().to_string()), + "{prompt}" + ); + } + + #[test] + fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", + ), + ); + + assert!(error + .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary=")); + assert!(error.contains(""), "{error}"); + assert!(error.contains(""), "{error}"); + assert!(!error.contains("secret"), "{error}"); + assert!(!error.contains("provider.example"), "{error}"); + + let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let entries = std::fs::read_dir(&diagnostics) + .expect("diagnostic directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let diagnostic_path = entries[0].path().join("failure.json"); + let diagnostic = std::fs::read_to_string(diagnostic_path).expect("diagnostic sidecar"); + assert!(diagnostic.contains("\"stage\": \"art-preparation\"")); + assert!(diagnostic.contains("\"retryable\": true")); + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains("secret")); + assert!(!diagnostic.contains("provider.example")); + } + + #[test] + fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", + ), + ); + + assert!( + error.contains("stage=art-preparation retryable=false"), + "{error}" + ); + assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); + } + + #[test] + fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", + ), + ); + + assert!( + error.contains("summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据"), + "{error}" + ); + assert!(error.contains("请检查当前 Windows 用户对本机私有凭据目录的权限后重试")); + assert!(!error.contains("[redacted sensitive context]"), "{error}"); + } + + fn direct_art_spec_identity_fixture() -> DirectTaonierArtAssetIdentity { + DirectTaonierArtAssetIdentity { + resource_id: "taonier-resource-icon-spec".to_string(), + asset_object_id: "taonier-object-icon-spec".to_string(), + canvas_project_id: "taonier-project".to_string(), + } + } + + #[test] + fn direct_source_only_spritesheet_recovery_accepts_single_current_spec_reference() { + let art_spec = direct_art_spec_identity_fixture(); + let resources = vec![serde_json::json!({ + "resourceId": "taonier-resource-spritesheet", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "assetObjectId": "taonier-object-spritesheet", + "taskId": "taonier-task-spritesheet", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + })]; + + let recovered = direct_recoverable_spritesheet_resource(&resources, &art_spec) + .expect("source-only identity must be accepted") + .expect("a single matching resource must be returned"); + + assert_eq!( + json_string_field(recovered, "resourceId").as_deref(), + Some("taonier-resource-spritesheet") + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_rejects_multiple_or_foreign_references() { + let art_spec = direct_art_spec_identity_fixture(); + let foreign_or_ambiguous = vec![ + serde_json::json!({ + "resourceId": "foreign-reference", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "someone-else" }] + } + }), + serde_json::json!({ + "resourceId": "multiple-references", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [ + { "refId": "taonier-resource-icon-spec" }, + { "refId": "another-spec" } + ] + } + }), + ]; + + assert!( + direct_recoverable_spritesheet_resource(&foreign_or_ambiguous, &art_spec) + .expect("unrelated entries are not an identity conflict") + .is_none() + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_fails_closed_when_multiple_candidates_match() { + let art_spec = direct_art_spec_identity_fixture(); + let candidates = vec![ + serde_json::json!({ + "resourceId": "candidate-one", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + serde_json::json!({ + "resourceId": "candidate-two", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + ]; + + let error = direct_recoverable_spritesheet_resource(&candidates, &art_spec) + .expect_err("multiple source-only candidates must fail closed"); + assert!(error.contains("身份不唯一"), "{error}"); + } + + #[test] + fn direct_source_preserved_recovery_is_limited_to_the_canonical_spritesheet() { + let error = + format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} postprocess failed"); + assert!(direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "ordinary platform failure", + )); + } + + #[test] + fn source_preserved_recovery_clears_the_completed_generation_ledger() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-ledger-cleanup", "源图恢复账本清理") + .expect("init project"); + let pending = setup_platform_art_generation_runtime_accepted_for_recovery_test( + root.path(), + "art-spritesheet", + ) + .expect("accepted generation ledger"); + let runtime_context = platform_art_generation_runtime_context_from_pending(&pending); + let ledger = root + .path() + .join(".agent/runtime/canvas-generation-requests/art-director/art-spritesheet.json"); + assert!(ledger.is_file(), "{pending:?}"); + + clear_direct_taonier_generation_ledger_after_source_recovery(root.path(), &runtime_context) + .expect("clear completed source-preserved ledger"); + + assert!(!ledger.exists()); + } + + fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["taonier-resource-icon-spec".to_string()], + ), + "game-background" => ( + "/api/external/v1/editor/images/generations", + "spec", + vec!["taonier-resource-icon-spec".to_string()], + ), + _ => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + }; + let mut png_bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageFormat::Png, + ) + .expect("encode visual asset"); + std::fs::write(root.join(local_path), &png_bytes).expect("visual asset"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some(format!("taonier-resource-{kind}")), + asset_object_id: Some(format!("taonier-object-{kind}")), + task_id: Some(format!("taonier-task-{kind}")), + prompt: None, + model: Some("gpt-image-2".to_string()), + generation_route: Some(generation_route.to_string()), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, + }, + ) + .expect("register TaoNier art asset"); + } + + fn register_direct_taonier_art_package_fixture(root: &Path) { + register_direct_taonier_art_base_fixture(root); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + ); + let slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .map(|(usage, path)| { + let absolute = root.join(path); + std::fs::create_dir_all(absolute.parent().expect("slice parent")) + .expect("slice directory"); + std::fs::write(&absolute, tiny_visible_png()).expect("slice png"); + serde_json::json!({ + "name": usage, + "usage": usage, + "path": path, + }) + }) + .collect::>(); + let slice_manifest = serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "sourceResourceId": "taonier-resource-art-spritesheet", + "sourceAssetObjectId": "taonier-object-art-spritesheet", + "sourceCanvasProjectId": "taonier-project", + "sourceReferenceResourceIds": ["taonier-resource-icon-spec"], + "slices": slices, + }); + std::fs::write( + root.join("assets/art-spritesheet-slices/manifest.json"), + serde_json::to_vec_pretty(&slice_manifest).expect("slice manifest JSON"), + ) + .expect("slice manifest"); + std::fs::create_dir_all(root.join(".agent/runtime")).expect("runtime dir"); + let contract_slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .map(|(usage, path)| serde_json::json!({ "usage": usage, "path": path })) + .collect::>(); + let receipt = serde_json::json!({ + "schemaVersion": "game-art-spritesheet-contract.v1", + "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "sourceResourceId": "taonier-resource-art-spritesheet", + "sourceAssetObjectId": "taonier-object-art-spritesheet", + "sourceCanvasProjectId": "taonier-project", + "sourceReferenceResourceIds": ["taonier-resource-icon-spec"], + "slices": contract_slices, + }); + std::fs::write( + root.join(".agent/runtime/art-spritesheet-contract.json"), + serde_json::to_vec_pretty(&receipt).expect("receipt JSON"), + ) + .expect("receipt"); + } + + fn register_direct_taonier_art_base_fixture(root: &Path) { + std::fs::create_dir_all(root.join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + ); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + ); + } + + fn tiny_visible_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tiny PNG"); + bytes + } + + fn tiny_transparent_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 0]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode transparent PNG"); + bytes + } + + fn write_minimal_direct_game_using_spritesheet(root: &Path) { + std::fs::write( + root.join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.join("game/game.js"), + "const art = new Image(); art.src = '/assets/art-spritesheet.png'; document.querySelector('#game').getContext('2d').drawImage(art, 0, 0);", + ) + .expect("script"); + } + + #[test] + fn direct_output_sync_registers_game_files_and_marks_prototype_completed() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{});", + ) + .expect("script"); + + let initial_fingerprint = direct_codex_output_fingerprint(root.path()); + sync_direct_codex_project_outputs_at(root.path(), Some(&initial_fingerprint)) + .expect("sync outputs"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/index.html" && asset.media_type == "text/html" + })); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/style.css" && asset.media_type == "text/css" + })); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/game.js" && asset.media_type == "text/javascript" + })); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + assert_eq!(manifest.versions.len(), 1); + assert_eq!(manifest.versions[0].version_id, "initial-1"); + assert_eq!(manifest.versions[0].project_revision, 1); + assert_eq!(manifest.versions[0].resource_bindings.len(), 6); + for expected_path in DIRECT_CODEX_ART_ASSET_PATHS { + assert!(manifest.versions[0] + .resource_bindings + .iter() + .any(|binding| { + manifest + .assets + .iter() + .find(|asset| asset.id == binding.resource_id) + .is_some_and(|asset| asset.local_path == expected_path) + })); + } + + let first_asset_ids = manifest + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(); + let first_output_fingerprint = direct_codex_output_fingerprint(root.path()); + std::fs::write( + root.path().join("game/game.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{}); // follow-up", + ) + .expect("follow-up script"); + + sync_direct_codex_project_outputs_at(root.path(), Some(&first_output_fingerprint)) + .expect("sync follow-up outputs"); + + let revision = read_game_creator_agent_runtime_project_revision(root.path()) + .expect("project revision"); + assert_eq!(revision.revision, 2); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.assets.len(), 6); + assert_eq!( + manifest + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + first_asset_ids + ); + assert_eq!(manifest.versions.len(), 2); + assert_eq!(manifest.versions[1].version_id, "agent-2"); + assert_eq!( + manifest.versions[1].parent_version_id.as_deref(), + Some("initial-1") + ); + assert_eq!(manifest.versions[1].project_revision, 2); + assert_eq!( + manifest.versions[1].created_reason, + GameIterationVersionCreatedReason::AgentRevision + ); + assert_eq!(manifest.versions[1].resource_bindings.len(), 6); + + let unchanged_fingerprint = direct_codex_output_fingerprint(root.path()); + sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint)) + .expect("sync unchanged outputs"); + let revision = read_game_creator_agent_runtime_project_revision(root.path()) + .expect("unchanged project revision"); + assert_eq!(revision.revision, 2); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 2); + } + + #[test] + fn direct_output_sync_refuses_code_without_a_complete_taonier_art_package() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + std::fs::write(root.path().join("game/index.html"), "").expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "requestAnimationFrame(()=>{});", + ) + .expect("script"); + + let error = sync_direct_codex_project_outputs_at(root.path(), None) + .expect_err("missing TaoNier art package must block completion"); + assert!(error.contains("缺少可用且已登记的陶泥儿平台美术资源")); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest + .assets + .iter() + .all(|asset| !asset.local_path.starts_with("game/"))); + assert_ne!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_output_sync_refuses_code_without_any_platform_art_reference() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const canvas = document.querySelector('canvas'); canvas.getContext('2d').fillRect(0, 0, 10, 10);", + ) + .expect("script"); + + let error = sync_direct_codex_project_outputs_at(root.path(), None) + .expect_err("code without a platform art reference must block completion"); + assert!( + error.contains("未在源码中引用任何已登记的陶泥儿平台图片"), + "{error}" + ); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest + .assets + .iter() + .all(|asset| !asset.local_path.starts_with("game/"))); + } + + #[test] + fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_base_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const background = new Image(); background.src = '/assets/direct-game-background.png'; document.querySelector('#game').getContext('2d').drawImage(background, 0, 0); requestAnimationFrame(()=>{});", + ) + .expect("script"); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("trusted TaoNier spec and background must be sufficient for direct completion"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert!(manifest + .assets + .iter() + .all(|asset| { asset.local_path != DIRECT_CODEX_SPRITESHEET_ASSET_PATH })); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_accepts_a_valid_platform_package_without_slices() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); + + assert!(direct_taonier_art_package_is_valid(root.path())); + } + + #[test] + fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); + write_minimal_direct_game_using_spritesheet(root.path()); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("a valid complete spritesheet must be a sufficient direct art path"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_rejects_a_corrupt_or_transparent_primary_png() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-integrity", "直连美术完整性") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + let spritesheet = root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + + std::fs::write(&spritesheet, b"not-a-png").expect("write corrupt PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a corrupt platform PNG must fail closed" + ); + + std::fs::write(&spritesheet, tiny_transparent_png()).expect("write transparent PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a fully transparent platform PNG must fail closed" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 7d8bb5289..e6d8ca19f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -24,6 +24,7 @@ pub(crate) use canvas_generation::{ }; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, + generate_platform_art_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, @@ -58,10 +59,12 @@ pub(crate) use canvas_generation::request_platform_art_asset_with_options_for_te #[allow(unused_imports)] pub(crate) use canvas_generation::{ build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at, - maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, - platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, + generate_platform_art_asset_with_options_at, + generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, + needs_platform_art_asset_generation, platform_art_asset_art_spec, + platform_art_asset_output_extension_matches, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ 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 0fe8bab76..0a1c36e55 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 @@ -133,6 +133,12 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr } pub(crate) fn editor_api_key_is_configured() -> bool { + if external_editor_api_credentials_override_is_active() { + return true; + } + if platform_editor_uses_account_routes() { + return platform_session_is_available(); + } load_game_creator_app_config() .ok() .and_then(|config| trim_config_string(&config.editor_api.api_key)) @@ -383,6 +389,19 @@ pub(crate) fn classify_external_generation_initial_response( match status { reqwest::StatusCode::OK => { let generated = external_editor_response_data(payload); + if let Some(queue_state) = generated + .get("queueState") + .filter(|queue_state| queue_state.is_object()) + { + if let Some(operation_id) = json_string_field(queue_state, "operationId") { + return Ok(ExternalGenerationInitialResponse::AsyncSubmission( + serde_json::json!({ + "operationId": operation_id, + "pollAfterMs": 2_000, + }), + )); + } + } if !external_generation_result_has_download_reference(generated) { return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果" @@ -430,7 +449,8 @@ pub(crate) async fn external_editor_json_request( .map_err(|error| format!("{action}失败:{error}"))?; let status = response.status(); if !status.is_success() { - return Err(format!("{action}失败:HTTP {}", status.as_u16())); + let body = response.text().await.unwrap_or_default(); + return Err(format_external_http_error(action, status, &body)); } response .json::() @@ -438,6 +458,92 @@ pub(crate) async fn external_editor_json_request( .map_err(|error| format!("解析{action}响应失败:{error}")) } +/// Keep provider validation details useful to the operator without copying an +/// entire response body (which may contain URLs, ids, paths or credentials). +fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String { + let detail = summarize_external_http_error_body(body); + match detail { + Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()), + None => format!("{action}失败:HTTP {}", status.as_u16()), + } +} + +fn summarize_external_http_error_body(body: &str) -> Option { + let payload = serde_json::from_str::(body).ok()?; + let mut fields = Vec::new(); + collect_external_http_error_fields(&payload, &mut fields); + if fields.is_empty() { + return None; + } + let mut detail = fields.join(";"); + detail = redact_secret_tokens(&detail); + detail = redact_absolute_path_tokens(&detail); + for field in ["operationId", "operation_id", "api_key", "access_token"] { + detail = redact_external_http_error_assignment(&detail, field); + } + detail.chars().take(600).collect::().into() +} + +fn redact_external_http_error_assignment(value: &str, field: &str) -> String { + let marker = format!("{field}="); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while let Some(offset) = value[cursor..].find(&marker) { + let start = cursor + offset; + output.push_str(&value[cursor..start]); + output.push_str(&marker); + output.push_str("[redacted]"); + let value_start = start + marker.len(); + let value_end = value[value_start..] + .char_indices() + .find_map(|(index, character)| { + matches!( + character, + ';' | ';' | ',' | ',' | '&' | '\n' | '\r' | '\t' | ' ' + ) + .then_some(value_start + index) + }) + .unwrap_or(value.len()); + cursor = value_end; + } + output.push_str(&value[cursor..]); + output +} + +fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Vec) { + const ALLOWED_KEYS: [&str; 7] = [ + "code", "field", "message", "reason", "detail", "error", "status", + ]; + match value { + serde_json::Value::Object(map) => { + for key in ALLOWED_KEYS { + if let Some(value) = map.get(key) { + match value { + serde_json::Value::String(text) if !text.trim().is_empty() => { + output.push(format!("{key}={}", text.trim())); + } + serde_json::Value::Number(number) => { + output.push(format!("{key}={number}")); + } + _ => collect_external_http_error_fields(value, output), + } + } + } + for (key, child) in map { + if !ALLOWED_KEYS.contains(&key.as_str()) { + collect_external_http_error_fields(child, output); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_external_http_error_fields(item, output); + } + } + _ => {} + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -467,9 +573,10 @@ pub(crate) async fn wait_for_external_generation_result( let submission = external_editor_response_data(submission_payload); let operation_id = json_string_field(submission, "operationId") .ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?; - let operation_id_path = - url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); - let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}"); + let status_url = format!( + "{api_base_url}{}", + resolve_platform_generation_status_route(&operation_id) + ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); @@ -504,7 +611,7 @@ pub(crate) async fn wait_for_external_generation_result( )); } }; - let generation = external_editor_response_data(&payload); + let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { Some("completed") => { let result = generation @@ -556,12 +663,32 @@ pub(crate) async fn submit_external_generation_request( idempotency_key: &str, request_body_json: &str, ) -> Result { + let mut request_body = serde_json::from_str::(request_body_json) + .map_err(|error| format!("解析平台图片生成请求失败:{error}"))?; + if platform_editor_uses_account_routes() { + let inputs = request_body.as_object_mut().and_then(|body| { + body.entry("generationInputs") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + }); + if let Some(inputs) = inputs { + inputs.insert( + "source".to_string(), + serde_json::Value::String("ai-game-creator-client".to_string()), + ); + } + } + let request_body_json = serde_json::to_string(&request_body) + .map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?; client - .post(format!("{api_base_url}{endpoint}")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(endpoint) + )) .bearer_auth(api_key) .header("Idempotency-Key", idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(request_body_json.to_string()) + .body(request_body_json) .send() .await .map_err(|error| { @@ -652,7 +779,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( }; let projects_payload = external_editor_json_request( client - .get(format!("{api_base_url}/api/external/v1/editor/projects")) + .get(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/projects") + )) .bearer_auth(api_key), "读取外部画布项目", ) @@ -670,7 +800,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( None => { let payload = external_editor_json_request( client - .post(format!("{api_base_url}/api/external/v1/editor/projects")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/projects") + )) .bearer_auth(api_key) .json(&serde_json::json!({ "title": canvas_name })), "创建外部画布项目", @@ -686,7 +819,8 @@ pub(crate) async fn prepare_external_canvas_generation_context( let library_payload = external_editor_json_request( client .get(format!( - "{api_base_url}/api/external/v1/editor/assets/library" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/assets/library") )) .bearer_auth(api_key), "读取外部素材库", @@ -707,7 +841,8 @@ pub(crate) async fn prepare_external_canvas_generation_context( let payload = external_editor_json_request( client .post(format!( - "{api_base_url}/api/external/v1/editor/assets/folders" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders") )) .bearer_auth(api_key) .json(&serde_json::json!({ "label": canvas_name })), @@ -791,6 +926,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { warning: Option, slice_warning: Option, slices: Vec, + spritesheet_slice_layout: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -850,7 +986,11 @@ fn canonical_art_spec_reference_at( } fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { - let project_context = truncate_inline(prompt.trim(), 320); + // External Editor validates each description independently (currently at + // 200 Unicode characters). Keep the gameplay context short enough that a + // long creation request cannot reject the atlas before it is queued. + const MAX_DESCRIPTION_CHARS: usize = 200; + const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; [ "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", @@ -858,10 +998,36 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", ] .into_iter() - .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}")) + .map(|category| { + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + format!("{category}{CONTEXT_PREFIX}{project_context}") + }) .collect() } +fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let actual_chars = normalized.chars().count(); + if actual_chars <= max_chars { + return normalized; + } + if max_chars <= 3 { + return ".".repeat(max_chars); + } + let mut output = normalized + .chars() + .take(max_chars.saturating_sub(3)) + .collect::(); + output.push_str("..."); + output +} + fn decode_platform_art_image_with_limits( download: &CanvasResourceDownload, label: &str, @@ -1099,7 +1265,7 @@ async fn prepare_platform_art_spritesheet_slices( Ok(prepared) } -pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( +pub(crate) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, briefs: &[AgentGroupBrief], @@ -1117,6 +1283,85 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(())) } +/// Generates the canonical game spritesheet together with the four durable +/// core slices. Callers that promise a playable game must use this instead +/// of the permissive asset path: a bare spritesheet is not enough evidence +/// that player, target, obstacle, and feedback visuals are available. +pub(crate) async fn generate_platform_art_asset_with_required_slices_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, +) -> Result { + if options.asset_kind != "art-spritesheet" { + return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + } + { + let recovery_lock = + acquire_project_write_lock(root, "canvas.asset_generate.strict.recover")?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; + } + let prepared = + request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?; + let lock = acquire_project_write_lock(root, "canvas.asset_generate.strict")?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; + advance_agent_runtime_project_revision_locked(root)?; + 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, @@ -1249,7 +1494,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; let canonical_reference = matches!( options.asset_kind.as_str(), - "ui-prototype" | "art-spritesheet" + "ui-prototype" | "game-background" | "art-spritesheet" ) .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) .transpose()?; @@ -1262,6 +1507,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "sliceLayout": "grid-2x2", "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -1346,6 +1592,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .await?; let status = response.status(); if !status.is_success() { + let body = response.text().await.unwrap_or_default(); if external_generation_submit_rejection_is_definitive(status) { if let Some(context) = runtime_context { remove_platform_art_generation_runtime_state_at( @@ -1354,15 +1601,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &context.run_id, )?; } - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + return Err(format_external_http_error( + "请求平台图片生成", + status, + &body, + )); } if runtime_context.is_some() { return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成提交返回 HTTP {},服务端是否已产生副作用未知", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成提交", status, &body) )); } - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + return Err(format_external_http_error( + "请求平台图片生成", + status, + &body, + )); } let submission_payload = response .json::() @@ -1498,6 +1753,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at Vec::new() }; let warning = platform_art_generation_warning(generated); + let spritesheet_slice_layout = if is_canonical_art_spritesheet { + json_string_field(generated, "sliceLayout") + } else { + None + }; let resource_id = json_string_field(resource, "resourceId"); let task_id = if is_canonical_art_spritesheet { consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])? @@ -1553,6 +1813,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at warning, slice_warning, slices, + spritesheet_slice_layout, generation_route, generation_kind, reference_resource_ids, @@ -4869,6 +5130,7 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, + spritesheet_slice_layout: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -4905,6 +5167,11 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("game-chat 图集生成 route/kind 与严格图集合同不一致".to_string()); } + if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { + return Err( + "game-chat 图集必须由 External Editor 以 grid-2x2 固定切片合同生成".to_string(), + ); + } if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -5248,6 +5515,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( warning, mut slice_warning, slices, + spritesheet_slice_layout, generation_route, generation_kind, reference_resource_ids, @@ -5266,6 +5534,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, + spritesheet_slice_layout.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -5685,6 +5954,17 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } + if options.asset_kind == "game-background" { + return serde_json::json!({ + "assetType": "background", + "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label), + "style": "原创横屏 Web 游戏场景背景;层次清晰,保留可玩区域的视觉留白,不包含 UI 或文字", + "composition": "严格 16:9 单场景背景,前景、中景、远景有明确层次;不出现角色、可收集物、操作按钮、HUD、Logo 或文字", + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "必须是可见的真实图片产物,适合作为 Canvas 或 HTML 游戏场景底图;不得做成素材图集、完整游戏截图、海报或概念板;必须原创,不得复刻现有游戏场景、Logo、贴图、标志性布局或受保护视觉语言", + "references": [], + }); + } serde_json::json!({ "assetType": "art", "subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label), @@ -5713,6 +5993,12 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } + if options.asset_kind == "game-background" { + return format!( + "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } let art_asset_brief = briefs .iter() .flat_map(|brief| brief.role_briefs.iter()) @@ -5737,6 +6023,28 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn external_http_error_summary_keeps_validation_details_without_sensitive_context() { + let body = serde_json::json!({ + "error": { + "code": "invalid-request", + "field": "sliceLayout", + "message": "只支持 grid-2x2;operationId=private-operation-id;api_key=private-key", + }, + "details": { + "path": "C:\\Users\\private\\secret.json", + } + }) + .to_string(); + let summary = summarize_external_http_error_body(&body).expect("summary"); + assert!(summary.contains("code=invalid-request"), "{summary}"); + assert!(summary.contains("field=sliceLayout"), "{summary}"); + assert!(summary.contains("只支持 grid-2x2"), "{summary}"); + assert!(!summary.contains("private-operation-id"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + assert!(!summary.contains("C:\\Users\\private"), "{summary}"); + } + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream .set_nonblocking(false) @@ -5857,13 +6165,22 @@ mod canvas_generation_tests { })).collect::>() }); - let slices = prepare_platform_art_spritesheet_slices( - &reqwest::Client::new(), - &base_url, - "test-api-key", - &generated, - 0, - 0, + let slices = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + prepare_platform_art_spritesheet_slices( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &generated, + 0, + 0, + ) + .await + }, ) .await .expect("prepare external icon slices"); @@ -5876,6 +6193,57 @@ mod canvas_generation_tests { assert!(slices.iter().all(|slice| slice.extension == "png")); } + #[test] + fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() { + let canvas_context = ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "contract-test".to_string(), + }; + let slices = (0..4) + .map(|index| { + let download = rgba_test_png(100 + index); + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "strict layout receipt test slice", + ) + .expect("fixture slice should validate"); + PreparedPlatformArtAssetSlice { + name: format!("素材 {}", index + 1), + width: validated.width, + height: validated.height, + download, + resource_id: Some(format!("slice-resource-{index}")), + asset_object_id: Some(format!("slice-object-{index}")), + canvas_project_id: Some("canvas-project".to_string()), + task_id: Some("spritesheet-task".to_string()), + source_resource_id: Some("spritesheet-resource".to_string()), + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: true, + extension: "png".to_string(), + } + }) + .collect::>(); + let error = validate_strict_platform_art_spritesheet_contract( + &slices, + &canvas_context, + Some("canvas-project"), + Some("spritesheet-resource"), + Some("spritesheet-object"), + Some("spritesheet-task"), + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + None, + &["art-spec-resource".to_string()], + true, + true, + ) + .expect_err("missing fixed-layout proof must not be accepted"); + + assert!(error.contains("grid-2x2")); + } + #[test] fn canonical_spritesheet_rejects_task_id_conflicts_across_all_identity_copies() { let generated = serde_json::json!({ @@ -6008,13 +6376,29 @@ mod canvas_generation_tests { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture"); + listener + .set_nonblocking(true) + .expect("set prepared recovery fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let server_base_url = base_url.clone(); let png = rgba_test_png(u8::MAX).bytes; let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { for request_index in 0..4 { - let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for prepared recovery request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept prepared recovery request: {error}"), + } + }; let request = read_test_http_request(&mut stream); request_sender .send(request) @@ -6075,15 +6459,6 @@ mod canvas_generation_tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "prepared-recovery-key" - } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -6121,27 +6496,36 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build first submit client"); - let first_error = submit_external_generation_request( - &first_submit_client, - &base_url, - "/api/external/v1/editor/images/generations", - "prepared-recovery-key", - &stable_key, - &stable_body, + let (first_error, prepared) = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "prepared-recovery-key".to_string(), + ), + async { + let first_error = submit_external_generation_request( + &first_submit_client, + &base_url, + "/api/external/v1/editor/images/generations", + "prepared-recovery-key", + &stable_key, + &stable_body, + ) + .await + .expect_err("first response is intentionally lost"); + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "恢复时不得重建这个提示词", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + .expect("resume prepared generation with the durable request"); + (first_error, prepared) + }, ) - .await - .expect_err("first response is intentionally lost"); + .await; assert!(platform_art_generation_error_result_unknown(&first_error)); - - let prepared = request_platform_art_asset_with_runtime_options_at( - root, - "恢复时不得重建这个提示词", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), - ) - .await - .expect("resume prepared generation with the durable request"); server.join().expect("join prepared recovery fixture"); assert_eq!( prepared.canvas_context.project_id, @@ -6236,14 +6620,23 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build prepared auth client"); - let error = resume_prepared_external_generation_at( - root, - &client, - &client, - &base_url, - "prepared-auth-key", - "/api/external/v1/editor/images/generations", - state, + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "prepared-auth-key".to_string(), + ), + async { + resume_prepared_external_generation_at( + root, + &client, + &client, + &base_url, + "prepared-auth-key", + "/api/external/v1/editor/images/generations", + state, + ) + .await + }, ) .await .expect_err("auth rejection cannot prove the original request was not accepted"); @@ -6319,17 +6712,26 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build polling client"); - let result = wait_for_external_generation_result( - &client, - &base_url, - "test-api-key", - &serde_json::json!({ - "data": { - "operationId": "task-async", - "status": "queued", - "pollAfterMs": 0 - } - }), + let result = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + wait_for_external_generation_result( + &client, + &base_url, + "test-api-key", + &serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 0 + } + }), + ) + .await + }, ) .await .expect("poll completed result"); @@ -6386,25 +6788,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": "测试切片告警" - } } } }) @@ -6438,14 +6844,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(), @@ -6530,7 +6932,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 @@ -6563,15 +6966,6 @@ mod canvas_generation_tests { .expect("set configuration drift fixture nonblocking"); let current_base_url = format!("http://{}", listener.local_addr().expect("fixture address")); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": current_base_url, - "apiKey": "current-editor-key" - } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -6602,18 +6996,27 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0) .expect("mark stale operation accepted"); - let error = match request_platform_art_asset_with_runtime_options_at( - root, - "不得覆盖旧请求", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + current_base_url, + "current-editor-key".to_string(), + ), + async { + match request_platform_art_asset_with_runtime_options_at( + root, + "不得覆盖旧请求", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("configuration drift must block GET-only recovery"), + } + }, ) - .await - { - Err(error) => error, - Ok(_) => panic!("configuration drift must block GET-only recovery"), - }; + .await; assert!(error.contains("服务地址身份"), "{error}"); assert!(matches!( listener.accept(), @@ -6643,10 +7046,26 @@ mod canvas_generation_tests { .expect("allow generation recovery"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted failure fixture"); + listener + .set_nonblocking(true) + .expect("set accepted failure fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept failed operation GET"); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for failed operation GET" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept failed operation GET: {error}"), + } + }; let request = read_test_http_request(&mut stream); request_sender .send(request) @@ -6668,12 +7087,6 @@ mod canvas_generation_tests { .write_all(response.as_bytes()) .expect("write failed operation response"); }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { "baseUrl": base_url.clone(), "apiKey": "accepted-failure-key" } - }) - .to_string(), - ); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -6706,18 +7119,27 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) .expect("mark failed operation accepted"); - let error = match request_platform_art_asset_with_runtime_options_at( - root, - "不得重新提交", - &[], - &PlatformArtAssetGenerationOptions::default(), - Some(&runtime_context), + let error = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url, + "accepted-failure-key".to_string(), + ), + async { + match request_platform_art_asset_with_runtime_options_at( + root, + "不得重新提交", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("explicit operation failure must be returned"), + } + }, ) - .await - { - Err(error) => error, - Ok(_) => panic!("explicit operation failure must be returned"), - }; + .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); assert!(request_receiver @@ -6773,7 +7195,7 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture accepted scan request"); - if request.starts_with("GET /api/external/v1/generations/test-operation-id ") { + if request.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ") { let body = serde_json::json!({ "data": { "operationId": "test-operation-id", @@ -6818,7 +7240,6 @@ mod canvas_generation_tests { ]); let _config_guard = crate::tests::write_test_local_config( serde_json::json!({ - "editorApi": { "baseUrl": base_url, "apiKey": "recovery-editor-key" }, "agentLlm": { "art-director": { "apiKey": "recovery-llm-key", @@ -6830,6 +7251,11 @@ mod canvas_generation_tests { }) .to_string(), ); + let _platform_session = crate::platform_session::install_test_platform_session( + "accepted-recovery-scan-user", + "recovery-editor-key", + &base_url, + ); let run_id = "accepted-recovery-scan-run"; setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) .expect("setup accepted generation recovery state"); @@ -6857,7 +7283,7 @@ mod canvas_generation_tests { stop_sender.send(()).expect("stop accepted scan fixture"); server.join().expect("join accepted scan fixture"); - assert!(first.starts_with("GET /api/external/v1/generations/test-operation-id ")); + assert!(first.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ")); assert!(second.starts_with("GET /artifact.png ")); assert!(request_receiver .recv_timeout(Duration::from_millis(100)) @@ -6927,11 +7353,24 @@ mod canvas_generation_tests { .timeout(Duration::from_secs(2)) .build() .expect("build legacy download client"); - let download = - resolve_canvas_resource_download(&client, &base_url, "test-api-key", &download_source) + let download = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + "test-api-key".to_string(), + ), + async { + resolve_canvas_resource_download( + &client, + &base_url, + "test-api-key", + &download_source, + ) .await - .expect("resolve legacy spritesheet download") - .expect("legacy spritesheet download"); + }, + ) + .await + .expect("resolve legacy spritesheet download") + .expect("legacy spritesheet download"); assert_eq!(download.bytes, expected_bytes); assert_eq!(download.media_type, "image/png"); let read_url_request = receiver @@ -7078,6 +7517,16 @@ mod canvas_generation_tests { } } + #[test] + fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { + let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); + + assert_eq!(descriptions.len(), 4); + assert!(descriptions + .iter() + .all(|description| description.chars().count() <= 200)); + } + #[test] fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); @@ -7214,6 +7663,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), + spritesheet_slice_layout: Some("grid-2x2".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -7529,6 +7979,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, + spritesheet_slice_layout: Some("grid-2x2".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs index e2d726e00..42c63705f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs @@ -259,10 +259,11 @@ pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result pub(crate) fn validate_game_html_smoke(html: &str) -> Result<(), String> { let lower_html = html.to_ascii_lowercase(); + validate_closed_game_script_blocks(&lower_html)?; + validate_executable_inline_javascript_syntax(html)?; if !lower_html.contains(" String { let normalized_base_url = api_base_url.trim().trim_end_matches('/'); - format!("{:x}", Sha256::digest(normalized_base_url.as_bytes())) + let account_identity = platform_session_service_identity().unwrap_or_default(); + format!( + "{:x}", + Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes()) + ) } pub(crate) fn platform_art_generation_external_service_origin( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs index 31381a004..5b82b57ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs @@ -7,7 +7,13 @@ pub(crate) async fn request_llm_game_draft_with_client( short_memory: &str, long_memory: &str, ) -> Result { - let llm = GameCreatorLlmConfig::default(); + // This helper exercises the one-shot OpenAI-compatible response fixture. + // Streaming has its own response-stream coverage; keeping this explicit + // prevents the production default from making the mock parse JSON as SSE. + let llm = GameCreatorLlmConfig { + stream: false, + ..GameCreatorLlmConfig::default() + }; request_generator_game_draft_with_client( client, &llm, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index 1c3ef53a9..db6594659 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -279,21 +279,12 @@ fn agent_interaction_system_prompt(agent_id: &str) -> String { ) } -fn build_agent_interaction_request_for_session( - root: &Path, +fn build_agent_interaction_llm_request( agent_id: &str, - session_id: &str, prompt: &str, -) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { - let prompt = prompt.trim(); - if prompt.is_empty() { - return Err("交互内容不能为空".to_string()); - } - if !game_creator_agent_uses_interaction_kernel(agent_id) { - return Err(format!("Agent 不启用交互决策层:{agent_id}")); - } - let (llm, config_path, context) = - build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + context: &str, + llm: &GameCreatorLlmConfig, +) -> Result { let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let user_prompt = if context.trim().is_empty() { format!("用户这轮输入:\n{prompt}") @@ -311,6 +302,25 @@ fn build_agent_interaction_request_for_session( .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) .with_function_tools(function_tools) .with_tool_choice(platform_llm::LlmToolChoice::Auto); + apply_game_creator_llm_reasoning_effort(request, llm) +} + +fn build_agent_interaction_request_for_session( + root: &Path, + agent_id: &str, + session_id: &str, + prompt: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("交互内容不能为空".to_string()); + } + if !game_creator_agent_uses_interaction_kernel(agent_id) { + return Err(format!("Agent 不启用交互决策层:{agent_id}")); + } + let (llm, config_path, context) = + build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?; + let request = build_agent_interaction_llm_request(agent_id, prompt, &context, &llm)?; Ok((llm, config_path, request)) } @@ -626,12 +636,16 @@ mod tests { #[test] fn interaction_request_crosses_neutral_provider_contract_without_shape_drift() { - let request = - LlmRunRequest::new(vec![LlmMessage::system("system"), LlmMessage::user("user")]) - .with_api_kind(LlmApiKind::OpenAiResponses) - .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) - .with_function_tools(agent_interaction_function_tools().expect("interaction tools")) - .with_tool_choice(platform_llm::LlmToolChoice::Auto); + let mut llm = GameCreatorLlmConfig::default(); + llm.api_kind = "openai_responses".to_string(); + llm.reasoning_effort = "max".to_string(); + let request = build_agent_interaction_llm_request( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个三消经营游戏", + "", + &llm, + ) + .expect("build interaction request"); let request = platform_llm::provider_request_from_llm_request("agent-interaction", request) .expect("neutral request"); assert_eq!( @@ -639,6 +653,10 @@ mod tests { Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) ); assert_eq!(request.tools().len(), 3); + assert_eq!( + request.reasoning_effort(), + Some(agent_runtime_core::ProviderReasoningEffort::Max) + ); assert!(request.tools().iter().all(|tool| tool.strict())); assert_eq!( request.tool_choice(), @@ -646,6 +664,20 @@ mod tests { ); } + #[test] + fn interaction_request_propagates_invalid_reasoning_effort() { + let mut llm = GameCreatorLlmConfig::default(); + llm.reasoning_effort = "maximum".to_string(); + let error = build_agent_interaction_llm_request( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "制作一个三消经营游戏", + "", + &llm, + ) + .expect_err("invalid reasoning effort must fail before Provider request"); + assert!(error.contains("reasoning_effort")); + } + #[test] fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() { let mut observed = Vec::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index d0082f2f5..2cecd843c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -285,6 +285,79 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( )?; return serde_json::to_string(&detail).ok(); } + if observation.tool == "command.run_limited" { + let summary = observation.summary.trim(); + if !summary.starts_with("game.static_smoke ") { + return None; + } + let passed = observation.status == "ok" && summary == "game.static_smoke 已完成"; + let failure_code = if passed { + None + } else { + Some(match summary { + "game.static_smoke 执行失败" => "static-smoke-failed", + "game.static_smoke 无法取得项目验证锁" => "verification-lock-unavailable", + "game.static_smoke 无法清除旧验证凭证" => "verification-reset-failed", + "game.static_smoke 结果无法形成有效验证凭证" => { + "verification-receipt-invalid" + } + _ => return None, + }) + }; + let diagnostic_check = observation.detail.as_deref().map_or("none", |detail| { + if detail.contains("JavaScript") { + "javascript-syntax" + } else { + "game-static-contract" + } + }); + let persisted = observation + .detail + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .filter(|value| { + value.get("commandId").and_then(serde_json::Value::as_str) + == Some("game.static_smoke") + }); + let diagnostic = if let Some(value) = persisted.as_ref() { + if value.get("passed").and_then(serde_json::Value::as_bool) != Some(passed) + || value.get("failureCode").and_then(serde_json::Value::as_str) != failure_code + { + return None; + } + value + .get("diagnostic") + .and_then(serde_json::Value::as_str) + .map(|value| redact_agent_runtime_error(root, value, 500)) + } else { + observation + .detail + .as_deref() + .map(|value| redact_agent_runtime_error(root, value, 500)) + }; + if !passed && diagnostic.is_none() { + return None; + } + if receipt_owner.is_none() { + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + })) + .ok(); + } + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "diagnostic": diagnostic, + })) + .ok(); + } if observation.tool == "image.inspect" { let detail = serde_json::from_str::( observation.detail.as_deref().unwrap_or_default(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 582e2f20c..dfbd62a90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -84,6 +84,11 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, tool) { return blocker; } + if let Some(blocker) = + agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool) + { + return blocker; + } if let Some(blocker) = game_chat_delegated_art_agent_input_mutation_block( root, agent_id, @@ -689,3 +694,172 @@ pub(in crate::agent) fn agent_runtime_pending_reconciliation_observation( detail: Some(redact_agent_runtime_project_paths(root, error, 500)), } } + +#[cfg(test)] +mod canvas_only_execution_tests { + use super::*; + + #[tokio::test] + async fn art_director_canvas_only_execution_rejects_file_write_and_mixed_batch_without_side_effects( + ) { + const PARENT_RUN_ID: &str = "canvas-only-execution-parent"; + const CHILD_ID: &str = "art-director"; + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"canvas-only-execution-test-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create canvas-only execution root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "canvas-only-execution-project", + "生成带统一视觉规范的完整游戏", + ) + .expect("init canvas-only execution project"); + + let parent_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve canvas-only parent session"); + let parent = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_session, + "生成带统一视觉规范的完整游戏", + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue canvas-only parent"); + let mut parent_state = agent_runtime_state_from_task_record(&parent); + parent_state.status = "running".to_string(); + parent_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("persist running canvas-only parent"); + + let task = format!( + "生成统一视觉规范图。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}" + ); + let child_session = resolve_agent_conversation_session_id_at(&root, CHILD_ID, None, true) + .expect("resolve canvas-only child session"); + let child_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, CHILD_ID); + let child = append_unique_game_creator_agent_runtime_pending_task( + &root, + CHILD_ID, + &child_session, + &task, + &child_run_id, + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue canvas-only child"); + let mut runtime = agent_runtime_state_from_task_record(&child); + runtime.status = "running".to_string(); + runtime.phase = "planning".to_string(); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist canvas-only child runtime"); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("persist running canvas-only child"); + update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Running) + .expect("mark canvas-only child running"); + + let forbidden_path = "game/art-director-forbidden.txt"; + let forbidden_action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("尝试写入非 Canvas 文件".to_string()), + input: serde_json::json!({ + "path": forbidden_path, + "content": "forbidden" + }), + }; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before canvas-only rejection"); + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + CHILD_ID, + &child_run_id, + &task, + &forbidden_action, + ) + .await; + assert_eq!(observation.status, "rejected", "{observation:?}"); + assert_eq!( + observation.summary, + "art-director 受控视觉任务拒绝非 Canvas 项目动作" + ); + assert!(!root.join(forbidden_path).exists()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after direct canvas-only rejection") + .revision, + revision_before.revision + ); + + let mixed_plan = AgentRuntimeToolPlan { + thinking_summary: "尝试混合文件写入与 Canvas 生成".to_string(), + plan_update: None, + plan: vec!["写入文件".to_string(), "生成视觉规范图".to_string()], + actions: vec![ + forbidden_action, + AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范图".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": AGENT_RUNTIME_ART_SPEC_PATH, + "assetKind": "icon-spec", + "aspectRatio": "1:1", + "imageSize": "1K", + "replaceExisting": false + }), + }, + ], + response: String::new(), + }; + let repository_fingerprint = build_repository_startup_context_at(&root) + .expect("build canvas-only repository context") + .fingerprint; + let prepared = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &task, + &mixed_plan, + &[], + &revision_before, + &repository_fingerprint, + ) + .await + .expect("prepare mixed canvas-only provider batch"); + assert!( + matches!( + &prepared, + AgentRuntimeProviderActionBatchPreparation::Aborted { + observation: AgentRuntimeToolObservation { + status, + summary, + .. + }, + .. + } if status == "blocked" + && summary == "art-director 受控视觉任务拒绝非 Canvas 项目动作" + ), + "mixed file.write + canvas batch must abort before execution: {prepared:?}" + ); + assert!(!root.join(forbidden_path).exists()); + assert!(!root.join(AGENT_RUNTIME_ART_SPEC_PATH).exists()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after mixed canvas-only rejection") + .revision, + revision_before.revision + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 39cde5bbd..55c1e2a64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -273,6 +273,8 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX: "自主构建固定 owner 的 Runtime 内部产物验证身份不可用"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str = "自主构建只读专业 Agent 禁止执行写入或副作用动作"; +pub(in crate::agent) const AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER: &str = + "唯一允许的项目写动作是 canvas.asset_generate"; pub(in crate::agent) fn agent_runtime_autonomous_uses_owner_artifact_validation( agent_id: &str, @@ -495,6 +497,49 @@ fn is_agent_runtime_autonomous_supervisor_delivery_convergence_plan( && action.input.get("delegation_id").is_none_or(serde_json::Value::is_null)) } +fn is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan( + plan: &AgentRuntimeToolPlan, +) -> bool { + let [task_list, run_status] = plan.actions.as_slice() else { + return false; + }; + let Some(task_list_input) = task_list.input.as_object() else { + return false; + }; + let Some(run_status_input) = run_status.input.as_object() else { + return false; + }; + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.plan.is_empty() + && task_list.tool.trim() == "task.list" + && task_list_input.is_empty() + && run_status.tool.trim() == "agent.run_status" + && run_status_input.len() == 3 + && run_status_input + .get("agentId") + .is_some_and(serde_json::Value::is_null) + && run_status_input + .get("scope") + .and_then(serde_json::Value::as_str) + == Some("all") + && run_status_input + .get("delegationId") + .is_some_and(serde_json::Value::is_null) +} + +fn is_agent_runtime_autonomous_supervisor_acceptance_control_plan( + plan: &AgentRuntimeToolPlan, +) -> bool { + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.actions.len() == 1 + && matches!( + plan.actions[0].tool.trim(), + "agent.action_history" | "agent.acceptance_update" + ) +} + fn is_agent_runtime_autonomous_preview_validation_plan(plan: &AgentRuntimeToolPlan) -> bool { matches!(plan.actions.as_slice(), [action] if action.tool.trim() == "preview.validate") } @@ -513,6 +558,35 @@ fn agent_runtime_autonomous_supervisor_plan_prepares_repair(plan: &AgentRuntimeT }) } +fn agent_runtime_game_chat_code_safe_default_repair_plan_matches( + plan: &AgentRuntimeToolPlan, + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + let [action] = plan.actions.as_slice() else { + return false; + }; + plan.response.trim().is_empty() + && plan.plan_update.is_none() + && plan.plan.is_empty() + && action.tool.trim() == "agent.delegate" + && agent_runtime_tool_input_text(&action.input, &["agentId", "agent_id"]) + == delivery.target_agent_id + && agent_runtime_tool_input_text( + &action.input, + &["repairOfDelegationId", "repair_of_delegation_id"], + ) == delivery.delegation_id + && agent_runtime_tool_input_text(&action.input, &["runId", "run_id"]).is_empty() + && !agent_runtime_tool_input_text(&action.input, &["task"]).is_empty() + && agent_runtime_tool_input_string_list( + &action.input, + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ) == delivery.acceptance_criteria + && agent_runtime_tool_input_string_list( + &action.input, + &["expectedArtifacts", "expected_artifacts", "artifacts"], + ) == delivery.expected_artifacts +} + pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( root: &Path, agent_id: &str, @@ -551,6 +625,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( observations: &[AgentRuntimeToolObservation], plan: &AgentRuntimeToolPlan, supervisor_requires_delegated_repair: bool, + supervisor_manifest_dag_in_progress_at_request: bool, ) -> Result<(), String> { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)? @@ -620,6 +695,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( )); } } + validate_game_chat_code_safe_default_repair_liveness_at(root, agent_id, run_id, plan)?; if autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)? && !plan.response.trim().is_empty() @@ -649,19 +725,56 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( observations, plan, supervisor_requires_delegated_repair, + supervisor_manifest_dag_in_progress_at_request, has_current_playtest_receipt, ) } -pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( +pub(in crate::agent) fn validate_game_chat_code_safe_default_repair_liveness_at( root: &Path, -) -> Result { + agent_id: &str, + run_id: &str, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if agent_id != "code-prototype" + || !static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id)? + { + return Ok(()); + } + if let Some(delivery) = + game_chat_code_parent_safe_default_repair_delivery_at(root, agent_id, run_id)? + { + if agent_runtime_game_chat_code_safe_default_repair_plan_matches(plan, &delivery) { + return Ok(()); + } + return Err(format!( + "game-chat code-prototype 必须立即推进唯一安全默认返工;本轮只能对 delegationId={}、targetAgentId={} 提交同合同 agent.delegate,不得重复 route、读取、查询或请求 Provider 空转", + delivery.delegation_id, delivery.target_agent_id + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) enum AutonomousManifestDagState { + NotStartedOrStalled, + InProgress, + Completed, + Failed { + failed_task_ids: Vec, + has_active_children: bool, + }, +} + +pub(in crate::agent) fn autonomous_manifest_dag_state_at( + root: &Path, +) -> Result { let manifest = read_manifest_for_project(root)?; let current_root = current_autonomous_game_build_root_task_at(root)?.ok_or_else(|| { "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() })?; if !autonomous_game_build_root_task_is_active(¤t_root) { - return Ok(false); + return Ok(AutonomousManifestDagState::NotStartedOrStalled); } let seed_task_ids = autonomous_manifest_seed_tasks_for_source(¤t_root.source) .into_iter() @@ -675,15 +788,43 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( let running = seed_tasks .iter() .any(|task| task.status == GameCreationAppTaskStatus::Running); - let failed = seed_tasks + let mut failed_task_ids = seed_tasks .iter() - .any(|task| task.status == GameCreationAppTaskStatus::Failed); + .filter(|task| task.status == GameCreationAppTaskStatus::Failed) + .map(|task| task.id.clone()) + .collect::>(); + failed_task_ids.sort(); let active_child = autonomous_manifest_parent_has_active_ready_task_at( root, ¤t_root.run_id, &seed_task_ids, )?; - Ok((running || active_child) && !failed) + if !failed_task_ids.is_empty() { + return Ok(AutonomousManifestDagState::Failed { + failed_task_ids, + has_active_children: running || active_child, + }); + } + if running || active_child { + return Ok(AutonomousManifestDagState::InProgress); + } + if seed_tasks.len() == seed_task_ids.len() + && seed_tasks + .iter() + .all(|task| task.status == GameCreationAppTaskStatus::Completed) + { + return Ok(AutonomousManifestDagState::Completed); + } + Ok(AutonomousManifestDagState::NotStartedOrStalled) +} + +pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( + root: &Path, +) -> Result { + Ok(matches!( + autonomous_manifest_dag_state_at(root)?, + AutonomousManifestDagState::InProgress + )) } fn autonomous_manifest_parent_has_active_ready_task_at( @@ -734,6 +875,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( observations: &[AgentRuntimeToolObservation], plan: &AgentRuntimeToolPlan, supervisor_requires_delegated_repair: bool, + supervisor_manifest_dag_in_progress_at_request: bool, has_current_playtest_receipt: bool, ) -> Result<(), String> { let has_mutation = plan @@ -762,9 +904,18 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( observations[index].status == "failed" && observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试" }); + let has_current_playtest_acceptance_control = agent_id + == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && has_current_playtest_receipt + && is_agent_runtime_autonomous_supervisor_acceptance_control_plan(plan); + let has_request_bound_manifest_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && supervisor_manifest_dag_in_progress_at_request + && is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(plan); // A Supervisor without a mutation must still hit the same first-mutation - // liveness gate as every other autonomous Agent. A concrete failed - // playtest is more specific and must reach its repair gate below. + // liveness gate as every other autonomous Agent. The only exception is + // the single-action evidence/update sequence after the current revision + // already owns a durable playtest receipt. A concrete failed playtest is + // more specific and must reach its repair gate below. if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT && verification_gate.mutation_revision.is_none() @@ -774,6 +925,8 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( && plan.response.trim().is_empty() && !has_specialist_delegation && !latest_playtest_is_failed + && !has_current_playtest_acceptance_control + && !has_request_bound_manifest_wait { return Err(format!( "{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX};Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转" @@ -990,6 +1143,86 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla )) } +fn agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool: &str) -> bool { + matches!( + tool, + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.read" + | "task.list" + | "agent.action_history" + | "agent.run_status" + | "command.output_read" + | "command.poll" + | "image.inspect" + | "canvas.asset_generate" + ) +} + +pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_task( + agent_id: &str, + task: &str, +) -> bool { + agent_id == "art-director" + && task.contains(AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER) +} + +pub(in crate::agent) fn validate_agent_runtime_autonomous_art_director_canvas_only_plan( + agent_id: &str, + task: &str, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task) { + return Ok(()); + } + if let Some(forbidden) = plan.actions.iter().find(|action| { + !agent_runtime_autonomous_art_director_canvas_only_tool_allowed(action.tool.trim()) + }) { + return Err(format!( + "autonomous art-director 受控视觉任务拒绝动作 {};只允许读取、image.inspect 与唯一 canvas.asset_generate 素材事务", + forbidden.tool.trim() + )); + } + let canvas_count = plan + .actions + .iter() + .filter(|action| action.tool.trim() == "canvas.asset_generate") + .count(); + if canvas_count > 1 { + return Err(format!( + "autonomous art-director 受控视觉任务单轮包含 {canvas_count} 个 canvas.asset_generate,最多允许 1 个" + )); + } + Ok(()) +} + +pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_action_block( + agent_id: &str, + task: &str, + tool: &str, +) -> Option { + if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task) + || agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool.trim()) + { + return None; + } + Some(AgentRuntimeToolObservation { + tool: tool.trim().to_string(), + status: "rejected".to_string(), + summary: "art-director 受控视觉任务拒绝非 Canvas 项目动作".to_string(), + detail: Some(format!( + "tool={} · allowed=read,image.inspect,canvas.asset_generate", + tool.trim() + )), + }) +} + pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery( root: &Path, agent_id: &str, @@ -1996,6 +2229,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2140,6 +2374,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2152,6 +2387,7 @@ mod tests { &route_manifest, false, false, + false, ) .is_ok()); for (agent_id, plan) in [ @@ -2167,6 +2403,85 @@ mod tests { } } + #[test] + fn autonomous_art_director_visual_task_allows_one_canvas_and_rejects_other_actions() { + let task = format!( + "生成视觉规范。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}" + ); + let action = |tool: &str| AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input: serde_json::json!({}), + }; + let allowed = AgentRuntimeToolPlan { + actions: vec![action("asset.list"), action("canvas.asset_generate")], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &allowed, + ) + .is_ok() + ); + assert!( + agent_runtime_autonomous_art_director_canvas_only_action_block( + "art-director", + &task, + "canvas.asset_generate", + ) + .is_none() + ); + + for tool in [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "command.run_limited", + "agent.delegate", + ] { + let rejected = AgentRuntimeToolPlan { + actions: vec![action(tool)], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &rejected, + ) + .is_err() + ); + assert!( + agent_runtime_autonomous_art_director_canvas_only_action_block( + "art-director", + &task, + tool, + ) + .is_some() + ); + } + + let duplicate_canvas = AgentRuntimeToolPlan { + actions: vec![ + action("canvas.asset_generate"), + action("canvas.asset_generate"), + ], + ..AgentRuntimeToolPlan::default() + }; + assert!( + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + "art-director", + &task, + &duplicate_canvas, + ) + .is_err() + ); + } + #[test] fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() { let verification_gate = AgentRuntimeVerificationGate { @@ -2181,6 +2496,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2197,6 +2513,7 @@ mod tests { &plan, false, false, + false, ) .expect_err("Supervisor without a playtest must not bypass pre-mutation liveness"); assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); @@ -2216,6 +2533,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2246,8 +2564,190 @@ mod tests { &plan, false, false, + false, ) .expect_err("latest successful playtest must not retain an earlier failure exemption"); assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); } + + #[test] + fn autonomous_supervisor_current_playtest_allows_only_acceptance_control_actions() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-current-playtest-acceptance".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let action = |tool: &str| AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input: serde_json::json!({}), + }; + + for tool in ["agent.action_history", "agent.acceptance_update"] { + let plan = AgentRuntimeToolPlan { + actions: vec![action(tool)], + ..AgentRuntimeToolPlan::default() + }; + validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 11, + &verification_gate, + &[], + &plan, + false, + false, + true, + ) + .unwrap_or_else(|error| { + panic!("{tool} should remain available for final acceptance: {error}") + }); + } + + let mixed_read = AgentRuntimeToolPlan { + actions: vec![action("agent.action_history"), action("file.read")], + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 11, + &verification_gate, + &[], + &mixed_read, + false, + false, + true, + ) + .expect_err("final acceptance exemption must not permit a mixed read batch"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } + + #[test] + fn autonomous_supervisor_accepts_one_request_bound_manifest_wait_when_live_predicate_closes() { + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + run_id: "supervisor-stale-manifest-wait".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, + failed_playtest_revision: None, + updated_at: 0, + }; + let plan = AgentRuntimeToolPlan { + actions: vec![ + AgentRuntimeToolAction { + tool: "task.list".to_string(), + reason: None, + input: serde_json::json!({}), + }, + AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: None, + input: serde_json::json!({ + "agentId": null, + "scope": "all", + "delegationId": null, + }), + }, + ], + ..AgentRuntimeToolPlan::default() + }; + assert!(is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(&plan)); + + validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &plan, + false, + true, + false, + ) + .expect("request-bound wait should survive the live predicate closing once"); + + let wait_without_request_binding = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &plan, + false, + false, + false, + ) + .expect_err("a request without a bound active observation must not keep waiting"); + assert!(wait_without_request_binding + .starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + + for invalid_plan in [ + AgentRuntimeToolPlan { + actions: vec![plan.actions[0].clone()], + ..AgentRuntimeToolPlan::default() + }, + AgentRuntimeToolPlan { + actions: vec![plan.actions[1].clone(), plan.actions[0].clone()], + ..AgentRuntimeToolPlan::default() + }, + AgentRuntimeToolPlan { + actions: vec![ + plan.actions[0].clone(), + AgentRuntimeToolAction { + input: serde_json::json!({ + "agentId": null, + "scope": "all", + "delegationId": null, + "extra": true, + }), + ..plan.actions[1].clone() + }, + ], + ..AgentRuntimeToolPlan::default() + }, + ] { + let error = validate_agent_runtime_autonomous_plan_liveness( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &invalid_plan, + false, + true, + false, + ) + .expect_err("request-bound wait exemption must reject any broader plan"); + assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)); + } + let response_plan = AgentRuntimeToolPlan { + response: "已经完成".to_string(), + ..plan.clone() + }; + assert!( + !is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan( + &response_plan + ) + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index ac9852d4b..be4893639 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -157,6 +157,7 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked( gate.last_verification_tool = None; gate.last_verification_status = None; gate.static_smoke_verified_revision = None; + gate.static_smoke_verified_game_index_sha256 = None; gate.failed_playtest_revision = None; gate.updated_at = now; if let Err(error) = write_game_creator_agent_runtime_verification_gate(root, &gate) { @@ -359,8 +360,59 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( ) -> Result<(), String> { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let revision_unchanged = current_revision.revision == expected_revision.revision; - let passed = passed && revision_unchanged; + let mut passed = passed && revision_unchanged; let verification_tool = gate.last_verification_tool.clone(); + let mut static_smoke_game_index_sha256 = None; + let mut static_smoke_credential_error = None; + if passed && verification_tool.as_deref() == Some("game.static_smoke") { + match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, bytes))) => { + let html = match std::str::from_utf8(&bytes) { + Ok(html) => html, + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 不是有效 UTF-8:{error}" + )); + "" + } + }; + if passed && !html.contains(" static_smoke_game_index_sha256 = Some(digest.sha256), + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 内容已变化且复核失败:{error}" + )); + } + } + } + } + Ok(None) => { + passed = false; + static_smoke_credential_error = + Some("game.static_smoke 通过后 game/index.html 缺失".to_string()); + } + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后无法绑定 game/index.html 摘要:{error}" + )); + } + } + } gate.verified_revision = passed .then_some(current_revision.revision) .filter(|value| *value > 0); @@ -376,6 +428,8 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( } if verification_tool.as_deref() == Some("game.static_smoke") { gate.static_smoke_verified_revision = passed.then_some(current_revision.revision); + gate.static_smoke_verified_game_index_sha256 = + passed.then_some(static_smoke_game_index_sha256).flatten(); } else if verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) { gate.static_smoke_verified_revision = None; } @@ -395,6 +449,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( expected_revision.revision, current_revision.revision )); } + if let Some(error) = static_smoke_credential_error { + return Err(error); + } Ok(()) } @@ -790,8 +847,17 @@ pub(in crate::agent) fn static_delegate_completion_blocker_at_locked( agent_id: &str, run_id: &str, ) -> Option { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return None; + match static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id) { + Ok(true) => {} + Ok(false) => return None, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.delegate_receipts".to_string(), + status: "blocked".to_string(), + summary: "无法确认当前父 Run 的专业 Agent 委派权限,不能收束当前任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } } match static_delegate_completion_barrier_at(root, agent_id, run_id) { Ok(barrier) if barrier.is_clear() => None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index 2425bb043..23beaedf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -649,6 +649,13 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit "当前 Agent 身份不允许执行该原始工具".to_string(), ) }); + let art_director_canvas_only_block = + agent_runtime_autonomous_art_director_canvas_only_action_block( + &runtime.agent_id, + task, + action.tool.trim(), + ) + .map(|observation| AgentRuntimeToolPolicyBlock::Denied(observation.summary)); let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block( root, &runtime.agent_id, @@ -670,6 +677,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit None }; let local_policy_block = identity_block + .or(art_director_canvas_only_block) .or(game_chat_art_scope_block) .or(isolated_scope_block) .or_else(|| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index c3e008db8..e0728fb76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -12,6 +12,63 @@ enum AgentBackgroundContextMode { FinalReply, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(in crate::agent) struct AgentRuntimeToolPlanRequestSnapshot { + pub(super) supervisor_manifest_dag_in_progress: bool, +} + +fn parse_manifest_seed_task_counts(detail: &str) -> Option<[u64; 6]> { + let counts = detail + .lines() + .find_map(|line| line.strip_prefix("seedTaskCounts: "))?; + let mut parsed = std::collections::BTreeMap::new(); + for field in counts.split_ascii_whitespace() { + let (key, value) = field.split_once('=')?; + if parsed.insert(key, value.parse::().ok()?).is_some() { + return None; + } + } + let completed = *parsed.get("completed")?; + let running = *parsed.get("running")?; + let pending = *parsed.get("pending")?; + let waiting = *parsed.get("waiting")?; + let failed = *parsed.get("failed")?; + let total = *parsed.get("total")?; + if parsed.len() != 6 + || completed + .checked_add(running)? + .checked_add(pending)? + .checked_add(waiting)? + .checked_add(failed)? + != total + { + return None; + } + Some([completed, running, pending, waiting, failed, total]) +} + +fn prompt_observations_report_manifest_dag_in_progress( + observations: &[AgentRuntimeToolObservation], +) -> bool { + let Some(observation) = observations + .iter() + .rfind(|observation| observation.tool == "task.list") + else { + return false; + }; + if observation.status != "ok" || observation.summary != "已读取 manifest 任务图" { + return false; + } + let Some([_completed, running, _pending, _waiting, _failed, total]) = observation + .detail + .as_deref() + .and_then(parse_manifest_seed_task_counts) + else { + return false; + }; + total > 0 && running > 0 +} + #[cfg(target_os = "linux")] fn provider_command_exec_contract() -> &'static str { "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令固定运行在 bubblewrap workspace-write、network-disabled 沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入" @@ -105,7 +162,16 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( observations: &[AgentRuntimeToolObservation], loop_index: usize, mcp_catalog: &GameCreatorMcpCatalog, -) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> { +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; if planning_agent { validate_project_planning_child_binding_at(root, agent_id, run_id)?; @@ -164,6 +230,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( && autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; let autonomous_project_verify_available = !runtime_owner_artifact_validation_available && (!autonomous_game_build || agent_runtime_autonomous_project_verify_available(root)); + let request_snapshot = AgentRuntimeToolPlanRequestSnapshot { + supervisor_manifest_dag_in_progress: autonomous_game_build + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && prompt_observations_report_manifest_dag_in_progress(&prompt_observations), + }; let mut allowed_tools = tool_policy.allowed_tools.clone(); let mut auto_tools = tool_policy.auto_tools.clone(); let mut confirm_tools = tool_policy.confirm_tools.clone(); @@ -280,7 +351,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n", "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n", "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n", - "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;Project Supervisor 传 delegationId 时读取当前父 run 的未截断权威返工合同。\n", + "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n", "当前请求中的每个 MCP 工具都以单独的动态函数广告;必须从实际广告函数中选择,并严格按该函数的 input schema 提交 arguments.input。server、tool、catalogFingerprint 和 toolFingerprint 由 Runtime 注入,禁止构造目录外包装调用。\n", "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。" ), @@ -390,7 +461,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .with_tool_choice(platform_llm::LlmToolChoice::Required); let request = apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false); - return Ok((llm, config_path, request, repository_context_fingerprint)); + return Ok(( + llm, + config_path, + request, + repository_context_fingerprint, + request_snapshot, + )); } // M1A-4:Supervisor 的 plan 根 run 需要在合成 system prompt 时收窄 // supervisorIntro / $visualContract 两段(见 prompt.rs),其余角色的 prompt @@ -571,7 +648,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( } else { apply_game_creator_llm_web_search(request, &llm, true)? }; - Ok((llm, config_path, request, repository_context_fingerprint)) + Ok(( + llm, + config_path, + request, + repository_context_fingerprint, + request_snapshot, + )) } pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( @@ -767,12 +850,13 @@ fn build_game_creator_background_agent_context( #[cfg(test)] mod tests { use crate::agent::{ - cancel_game_creator_agent_runtime_task_at, + autonomous_manifest_dag_in_progress_at, cancel_game_creator_agent_runtime_task_at, persist_game_chat_supervisor_workflow_decision_at, start_game_creator_supervisor_background_task_for_session_at, try_acquire_game_creator_agent_runtime_task_lock, GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST, }; + use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus}; use super::{ agent_runtime_root_source_at, append_unique_game_creator_agent_runtime_pending_task, @@ -788,6 +872,7 @@ mod tests { start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, @@ -876,7 +961,7 @@ mod tests { summary: "结构化计划更新被 Runtime 拒绝".to_string(), detail: Some("计划状态回退".to_string()), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -987,7 +1072,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, agent_id, &state.session_id, @@ -1064,7 +1149,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, agent_id, &state.session_id, @@ -1302,7 +1387,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -1365,7 +1450,7 @@ mod tests { tools: Vec::new(), }; - let (_, _, first, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -1433,7 +1518,7 @@ mod tests { }, ) .expect("freeze plan contract"); - let (_, _, later, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &state.agent_id, &state.session_id, @@ -1522,17 +1607,18 @@ mod tests { vec!["核对预加载说明".to_string()], ) .expect("start supervisor runtime state"); - let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &[], - 0, - &catalog, - ) - .expect("build supervisor planning request"); + let (_, _, supervisor_request, _, _) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_state.session_id, + &supervisor_state.run_id, + &supervisor_state.current_task, + &[], + 0, + &catalog, + ) + .expect("build supervisor planning request"); let supervisor_system_prompt = &supervisor_request.messages[0].content; let supervisor_prompt = &supervisor_request.messages[1].content; let supervisor_identity_contract = @@ -1677,7 +1763,7 @@ mod tests { vec!["核对普通说明".to_string()], ) .expect("start ordinary runtime state"); - let (_, _, ordinary_request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &ordinary_state.session_id, @@ -1806,7 +1892,7 @@ mod tests { vec!["核对 MCP 调用协议".to_string()], ) .expect("start runtime state"); - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &state.session_id, @@ -1881,7 +1967,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, planning_request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, &planning_state.session_id, @@ -1967,17 +2053,18 @@ mod tests { vec!["准备委派".to_string()], ) .expect("start supervisor"); - let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &[], - 0, - &catalog, - ) - .expect("build supervisor request"); + let (_, _, supervisor_request, _, _) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_state.session_id, + &supervisor_state.run_id, + &supervisor_state.current_task, + &[], + 0, + &catalog, + ) + .expect("build supervisor request"); assert!(!supervisor_request.messages[0] .content .contains(planning_brief_marker)); @@ -2106,7 +2193,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &state.session_id, @@ -2128,6 +2215,135 @@ mod tests { assert!(!user_prompt.contains("Graph reset 才保留")); } + #[test] + fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure() + { + let temporary = + crate::tests::canonical_test_tempdir("provider-running-sibling-after-failure-"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "running-sibling-after-failure", + "失败兄弟任务后的活跃观察绑定测试", + ) + .expect("project init"); + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let run_id = "provider-stale-manifest-observation-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "生成一版可玩的游戏", + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "等待专业任务图完成", + vec!["读取专业任务图状态".to_string()], + ) + .expect("start autonomous supervisor"); + for task_id in [ + "design-director", + "design-foundation", + "balance-director", + "balance-seed", + "art-director", + "art-polish", + "audio-director", + ] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete manifest fixture task"); + } + update_manifest_task_status_at(&root, "art-asset-plan", GameCreationAppTaskStatus::Failed) + .expect("fail manifest fixture task"); + update_manifest_task_status_at( + &root, + "audio-asset-plan", + GameCreationAppTaskStatus::Running, + ) + .expect("keep sibling manifest fixture task running"); + assert!( + !autonomous_manifest_dag_in_progress_at(&root) + .expect("read failure-closed live DAG predicate"), + "the regression requires a failed task to close the live predicate while a sibling remains running" + ); + let mut observations = vec![ + AgentRuntimeToolObservation { + tool: "task.list".to_string(), + status: "ok".to_string(), + summary: "已读取 manifest 任务图".to_string(), + detail: Some( + "readyTaskIds: (none)\nseedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16\ntaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16" + .to_string(), + ), + }, + AgentRuntimeToolObservation { + tool: "agent.run_status".to_string(), + status: "ok".to_string(), + summary: "已读取 2 个 Agent 状态".to_string(), + detail: Some( + "agentId: project-supervisor\nstatus: running\n\nagentId: code-prototype\nstatus: running" + .to_string(), + ), + }, + ]; + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + + let (_, _, request, _, request_snapshot) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + &state.run_id, + &state.current_task, + &observations, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + &catalog, + ) + .expect("build request from the provider-visible running sibling observation"); + + assert!(request.messages[1].content.contains( + "seedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16" + )); + assert!(request_snapshot.supervisor_manifest_dag_in_progress); + + observations.push(AgentRuntimeToolObservation { + tool: "task.list".to_string(), + status: "ok".to_string(), + summary: "已读取 manifest 任务图".to_string(), + detail: Some( + "readyTaskIds: (none)\nseedTaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16\ntaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16" + .to_string(), + ), + }); + let (_, _, _, _, settled_request_snapshot) = + build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + &state.run_id, + &state.current_task, + &observations, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 2, + &catalog, + ) + .expect("build successor request from the terminal provider-visible observation"); + assert!( + !settled_request_snapshot.supervisor_manifest_dag_in_progress, + "a successor request must not inherit an older active observation" + ); + } + #[test] fn game_chat_code_prototype_receives_the_persisted_supervisor_workflow_authority() { let temporary = crate::tests::canonical_test_tempdir("provider-game-chat-authority-"); @@ -2190,7 +2406,7 @@ mod tests { servers: Vec::new(), tools: Vec::new(), }; - let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, "code-prototype", &code_state.session_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 98df90b05..2218aa0dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -216,12 +216,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } else { read_game_creator_mcp_catalog_at(root).await? }; - let mut built_request = { + let (mut built_request, mut supervisor_manifest_dag_in_progress_at_request) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.build.tool_plan", )?; - build_game_creator_agent_background_tool_plan_request( + let live_manifest_dag_in_progress_before = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -230,7 +234,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, &mcp_catalog, - )? + )?; + let live_manifest_dag_in_progress_after = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress; + (request, request_bound_manifest_dag_in_progress) }; let mut estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; let mut compaction = None; @@ -266,12 +278,19 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at return Ok(RequestedAgentRuntimeToolPlanOutcome::Superseded); } } - built_request = { + ( + built_request, + supervisor_manifest_dag_in_progress_at_request, + ) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.rebuild.tool_plan", )?; - build_game_creator_agent_background_tool_plan_request( + let live_manifest_dag_in_progress_before = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -280,7 +299,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, loop_index, &mcp_catalog, - )? + )?; + let live_manifest_dag_in_progress_after = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && autonomous_manifest_dag_in_progress_at(root)?; + let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before + || live_manifest_dag_in_progress_after + || request.4.supervisor_manifest_dag_in_progress; + (request, request_bound_manifest_dag_in_progress) }; estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; if estimated_input_tokens > built_request.0.auto_compact_token_limit { @@ -374,7 +401,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "锁内冻结后的 tool-plan 请求", )?; } - let (llm, config_path, mut request, repository_context_fingerprint) = built_request; + let (llm, config_path, mut request, repository_context_fingerprint, _) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS @@ -621,6 +648,17 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at error, ) })?; + validate_agent_runtime_autonomous_art_director_canvas_only_plan( + agent_id, + task, + &parsed.plan, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; if autonomous_scaffold_repair_active && source_payload.max_field_chars > AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS @@ -684,6 +722,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at observations, &parsed.plan, supervisor_requires_delegated_repair, + supervisor_manifest_dag_in_progress_at_request && repair_attempt == 0, ) { Ok(()) => Ok((parsed, source_payload)), Err(error) => Err(AgentRuntimeToolPlanProtocolError::new( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 938455dd8..860ec9622 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -518,10 +518,6 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { #[test] fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - assert!( - !GameCreatorLlmConfig::default().stream, - "the production default exercises the non-stream final-reply path" - ); let project = tempfile::tempdir().expect("create non-stream professional reply project"); let root = project.path(); init_local_game_project_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs index 2a150f911..7ac22121c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs @@ -12,7 +12,7 @@ pub(in crate::agent) fn mark_supervisor_delivery_claims_observed_for_pending_act observed_delegate_receipt_ids_from_run_status(observation.detail.as_deref())?; let observed_isolated_groups = observed_isolated_join_group_ids_from_run_status(observation.detail.as_deref())?; - if pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if static_delegate_parent_can_manage_receipts_at(root, &pending.agent_id, &pending.run_id)? { mark_static_delegate_claim_observed_for_receipts_at( root, &pending.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs index d938862f3..0617863b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs @@ -120,8 +120,9 @@ pub(crate) fn apply_agent_runtime_plan_update( update: &AgentRuntimePlanUpdate, ) -> Result { let update = sanitize_agent_runtime_plan_update(update)?; + let had_structured_plan = agent_runtime_has_structured_plan(runtime); let mut terminal_steps = std::collections::BTreeMap::new(); - if agent_runtime_has_structured_plan(runtime) { + if had_structured_plan { for step in &runtime.plan_steps { if matches!( step.status.as_str(), @@ -155,10 +156,12 @@ pub(crate) fn apply_agent_runtime_plan_update( .plan_steps .iter() .filter(|step| { - matches!( - step.status.as_str(), - AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED - ) && !incoming_titles.contains(step.title.as_str()) + had_structured_plan + && matches!( + step.status.as_str(), + AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED + ) + && !incoming_titles.contains(step.title.as_str()) }) .map(|step| (step.title.clone(), step.status.clone())) .collect::>(); @@ -175,7 +178,7 @@ pub(crate) fn apply_agent_runtime_plan_update( )); } - let unchanged = agent_runtime_has_structured_plan(runtime) + let unchanged = had_structured_plan && runtime.plan_explanation == update.explanation && runtime.plan_steps.len() == merged.len() && runtime @@ -190,11 +193,15 @@ pub(crate) fn apply_agent_runtime_plan_update( } let now = unix_timestamp(); - let previous_steps = runtime - .plan_steps - .iter() - .map(|step| (step.title.clone(), step.clone())) - .collect::>(); + let previous_steps = if had_structured_plan { + runtime + .plan_steps + .iter() + .map(|step| (step.title.clone(), step.clone())) + .collect::>() + } else { + std::collections::BTreeMap::new() + }; runtime.plan_steps = merged .into_iter() .enumerate() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index c268911a0..5a8b856a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -505,7 +505,7 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, - configure_game_creator_manifest_invalidation_event_sink, + configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, generate_local_game_draft_at, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, 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 16f9e93b2..09659a149 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 @@ -14,6 +14,20 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } +pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; + let _ = app.emit( + "game-creator-agent-progress", + GameCreatorAgentProgressEvent { + project_path: root.to_string_lossy().into_owned(), + stage: stage.to_string(), + message: message.to_string(), + }, + ); +} + 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/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index ae8e3fc0d..d922fe718 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -165,6 +165,22 @@ fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRunt } } +fn game_chat_fast_path_safe_default_repair_plan( + delivery: &StaticDelegateDeliveryRecord, +) -> AgentRuntimeToolPlan { + game_chat_fast_path_action( + "agent.delegate", + serde_json::json!({ + "agentId": delivery.target_agent_id, + "task": "不要追问用户;采用原回执中已授权的安全默认值,严格按原合同完成唯一一次返工。", + "acceptanceCriteria": delivery.acceptance_criteria, + "expectedArtifacts": delivery.expected_artifacts, + "repairOfDelegationId": delivery.delegation_id, + "runId": null, + }), + ) +} + fn game_chat_fast_path_fallback_write_plan_for_root( root: &Path, task: &str, @@ -2243,8 +2259,7 @@ fn game_chat_fast_path_current_revision_is_verified( &runtime.agent_id, &runtime.run_id, )?; - Ok(revision.revision > 0 - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision)) + agent_runtime_static_smoke_passed_for_current_entry_at(root, &gate, revision.revision) } fn game_chat_fast_path_is_main_runtime( @@ -2465,6 +2480,50 @@ pub(crate) fn game_chat_fast_path_plan_at( } } "code-prototype" => { + if !static_delegate_parent_can_manage_receipts_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Err( + "game-chat code-prototype 父 Run 身份链不可信,拒绝管理专业美术回执" + .to_string(), + ); + } + let delegate_barrier = + static_delegate_completion_barrier_at(root, &runtime.agent_id, &runtime.run_id)?; + if delegate_barrier.ready_unclaimed_count > 0 { + return Ok(Some(game_chat_fast_path_action( + "agent.run_status", + serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": null, + }), + ))); + } + if delegate_barrier.unobserved_claim_count > 0 { + return Err( + "game-chat 专业美术回执 claim 尚未由原 agent.run_status action 完整观察;拒绝发起新的 Provider 请求" + .to_string(), + ); + } + if let Some(error) = game_chat_code_parent_terminal_delivery_error_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Err(error); + } + if let Some(delivery) = game_chat_code_parent_safe_default_repair_delivery_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? { + return Ok(Some(game_chat_fast_path_safe_default_repair_plan( + &delivery, + ))); + } if agent_runtime_has_structured_plan(runtime) && runtime .plan_steps @@ -2489,7 +2548,11 @@ pub(crate) fn game_chat_fast_path_plan_at( revision.revision, )?; let current_revision_static_smoke_passed = owns_current_mutation - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision); + && agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + )?; let current_revision_failed = owns_current_mutation && gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); @@ -2539,8 +2602,11 @@ pub(crate) fn game_chat_fast_path_plan_at( { return Ok(None); } - if runtime.loop_iteration > 1 - || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + if budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + || (runtime.loop_iteration > 1 + && current_route.is_some() + && game_chat_fast_path_has_visual_asset(root, "art-asset-plan") + && game_chat_fast_path_art_slice_paths(root).is_ok()) { return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at( root, &budget, task, @@ -3896,6 +3962,10 @@ mod tests { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.static_smoke_verified_revision = Some(1); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write static smoke gate"); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index c5dfd1a9c..cacdeb5d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1202,35 +1202,59 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) .is_none(); if autonomous_manifest_parent_can_wait { + let manifest_state_before_schedule = match autonomous_manifest_dag_state_at(&root) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建 manifest 等待屏障失败:{error}"), + ); + } + }; // 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的 // manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作, // 父 Run 必须继续等待,不能提前落入 game-chat fixed-graph-stalled。 - let scheduled_ready_tasks = - if autonomous_registered_derived_visuals_block_manifest_scheduler_at( + // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再 + // 启动新的 pending sibling 并用它遮蔽原始失败。 + let manifest_scheduler_blocked = matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Completed + | AutonomousManifestDagState::Failed { .. } + ) + || autonomous_registered_derived_visuals_block_manifest_scheduler_at( &root, &runtime, + ); + let scheduled_ready_tasks = if manifest_scheduler_blocked { + Vec::new() + } else { + match schedule_autonomous_game_build_ready_tasks_at( + &root, + &agent_id, + &runtime.run_id, + 3, ) { - Vec::new() - } else { - match schedule_autonomous_game_build_ready_tasks_at( - &root, - &agent_id, - &runtime.run_id, - 3, - ) { - Ok(tasks) => tasks, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("调度自主构建 manifest 任务失败:{error}"), - ); - } + Ok(tasks) => tasks, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("调度自主构建 manifest 任务失败:{error}"), + ); } - }; - let manifest_in_progress = if scheduled_ready_tasks.is_empty() { - match autonomous_manifest_dag_in_progress_at(&root) { + } + }; + let manifest_state = if matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Failed { .. } + ) { + manifest_state_before_schedule + } else if scheduled_ready_tasks.is_empty() { + match autonomous_manifest_dag_state_at(&root) { Ok(value) => value, Err(error) => { return fail_game_creator_agent_background_context_at( @@ -1243,9 +1267,17 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } } else { - true + AutonomousManifestDagState::InProgress }; - if manifest_in_progress { + let manifest_parent_must_wait = matches!( + &manifest_state, + AutonomousManifestDagState::InProgress + | AutonomousManifestDagState::Failed { + has_active_children: true, + .. + } + ); + if manifest_parent_must_wait { let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &runtime) .unwrap_or_else(|| AgentRuntimeToolObservation { @@ -1274,6 +1306,21 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } return AgentBackgroundTaskOutcome::WaitingForManifestTasks; } + if let AutonomousManifestDagState::Failed { + failed_task_ids, .. + } = manifest_state + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "自主构建 manifest 专业任务失败;failedTaskIds={}", + failed_task_ids.join(",") + ), + ); + } } match prepare_game_chat_single_round_convergence_at( &root, @@ -2147,21 +2194,101 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;不得把用户修订计入 repair_depth".to_string(); } else if user_input_required { - // The background drain owns the Supervisor execution lane. Persist a - // receipt wait first, then let task_queue schedule parent-wake after this - // pass returns and the lane is released. Parent-wake acquires - // project -> execution and creates the unique clarification pending under - // both locks; doing that here would invert the M1C-2b planning projection - // order. - runtime.phase = "waiting-for-delegate-receipts".to_string(); - runtime.current_action = - "等待创建 Project Supervisor 用户澄清请求".to_string(); - runtime.waiting_on = - "释放当前 execution lane 后投影 planning session 与澄清 pending" - .to_string(); - runtime.next_step = - "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求" - .to_string(); + let parent_task = + match read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &runtime.agent_id, + &runtime.run_id, + ) { + Ok(Some(task)) => task, + Ok(None) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "game-chat needs-user-input 缺少父任务", + ); + } + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 needs-user-input 父任务失败:{error}"), + ); + } + }; + let game_chat_safe_default_parent = + match trusted_game_chat_autonomous_root_parent_at(&root, &parent_task) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "校验 game-chat needs-user-input 父链失败:{error}" + ), + ); + } + }; + if game_chat_safe_default_parent { + let deliveries = match claimed_static_delegate_deliveries_at( + &root, + &runtime.agent_id, + &runtime.run_id, + ) { + Ok(deliveries) => deliveries, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 needs-user-input 回执失败:{error}"), + ); + } + }; + let waiting_for_user = + match ensure_static_delegate_user_input_wait_at( + &root, + &mut runtime, + &deliveries, + ) { + Ok(waiting) => waiting, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), + ); + } + }; + if waiting_for_user { + return AgentBackgroundTaskOutcome::WaitingForUserInput; + } + runtime.phase = "planning".to_string(); + runtime.current_action = + "等待 Project Supervisor 采用安全默认值发起唯一返工".to_string(); + runtime.waiting_on = + "已转换为 needs-repair 的自主构建专业回执".to_string(); + runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;返工任务必须采用安全默认值继续,不能再询问用户".to_string(); + } else { + runtime.phase = "waiting-for-delegate-receipts".to_string(); + runtime.current_action = + "等待创建 Project Supervisor 用户澄清请求".to_string(); + runtime.waiting_on = + "释放当前 execution lane 后投影 planning session 与澄清 pending" + .to_string(); + runtime.next_step = + "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求" + .to_string(); + } } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index e0bd9537a..25309df73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -202,11 +202,7 @@ fn queue_game_chat_fast_path_child( ) } -fn persist_game_chat_main_asset_audit_and_route( - root: &Path, - root_state: &AgentRuntimeState, - main_state: &mut AgentRuntimeState, -) { +fn persist_game_chat_main_asset_audit(root: &Path, main_state: &mut AgentRuntimeState) { assert_eq!(main_state.agent_id, "code-prototype"); main_state .recent_tool_calls @@ -225,6 +221,14 @@ fn persist_game_chat_main_asset_audit_and_route( .expect("persist game-chat main asset audit task"); write_game_creator_agent_runtime_state(root, main_state) .expect("persist game-chat main asset audit runtime"); +} + +fn persist_game_chat_main_asset_audit_and_route( + root: &Path, + root_state: &AgentRuntimeState, + main_state: &mut AgentRuntimeState, +) { + persist_game_chat_main_asset_audit(root, main_state); persist_game_chat_supervisor_workflow_decision_at( root, &root_state.agent_id, @@ -369,851 +373,47 @@ fn game_chat_main_art_child_fixture( (main, child, delegation_id) } -fn snapshot_agent_durable_files(root: &Path) -> std::collections::BTreeMap> { - fn visit( - base: &Path, - directory: &Path, - snapshot: &mut std::collections::BTreeMap>, - ) { - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - for entry in entries { - let entry = entry.expect("read durable Agent entry"); - let path = entry.path(); - let file_type = entry.file_type().expect("read durable Agent file type"); - if file_type.is_dir() { - visit(base, &path, snapshot); - } else if file_type.is_file() { - if path - .file_name() - .and_then(|value| value.to_str()) - .is_some_and(|value| value.ends_with(".lock")) - { - continue; - } - let relative = path - .strip_prefix(base) - .expect("durable Agent file remains below project root") - .to_string_lossy() - .replace('\\', "/"); - snapshot.insert(relative, fs::read(&path).expect("read durable Agent file")); - } - } - } - - let mut snapshot = std::collections::BTreeMap::new(); - visit(root, &root.join(".agent"), &mut snapshot); - snapshot -} - -fn persist_legacy_game_chat_art_retry_run( +fn claim_game_chat_main_ready_deliveries_with_run_status( root: &Path, - original_child: &AgentRuntimeState, - retry_run_id: &str, -) -> AgentRuntimeState { - let parent_agent_id = original_child - .parent_agent_id - .as_deref() - .expect("game-chat art child has parent agent"); - let parent_run_id = original_child - .parent_run_id - .as_deref() - .expect("game-chat art child has parent run"); - let retry_link = AgentRuntimeTaskLink { - parent_agent_id: Some(parent_agent_id.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: Some(format!("legacy-retry-{retry_run_id}")), - }; - let binding = bind_game_creator_agent_runtime_run_profile_at( + main: &AgentRuntimeState, +) -> (AgentRuntimePendingToolAction, AgentRuntimeToolObservation) { + let plan = game_chat_fast_path_plan_at(root, main, &main.current_task, unix_timestamp()) + .expect("build deterministic game-chat receipt claim plan") + .expect("ready game-chat delivery must produce a deterministic claim plan"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "agent.run_status"); + let action = plan.actions[0].clone(); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read game-chat receipt claim revision"); + let repository_context_fingerprint = build_repository_startup_context_at(root) + .expect("build game-chat receipt claim repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( root, - &original_child.agent_id, - retry_run_id, - "agent-delegate-retry", - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - Some(&retry_link), + main, + &main.current_task, + &plan, + &[], + &revision, + &repository_context_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, ) - .expect("bind legacy game-chat art retry run"); - let mut retry = read_latest_game_creator_agent_runtime_task_by_run_id( + .expect("build game-chat receipt claim pending action"); + let observation = observe_agent_runtime_run_status( root, - &original_child.agent_id, - &original_child.run_id, - ) - .expect("read original game-chat art child") - .expect("original game-chat art child exists"); - retry.run_id = retry_run_id.to_string(); - retry.source = "agent-delegate-retry".to_string(); - retry.run_profile = binding.profile; - retry.run_profile_binding_fingerprint = binding.binding_fingerprint; - retry.delegation_id = retry_link.delegation_id; - retry.status = "running".to_string(); - retry.phase = "planning".to_string(); - retry.current_action = "遗留 retry 等待执行".to_string(); - retry.terminal_detail = None; - retry.error = None; - retry.updated_at = unix_timestamp(); - let state = agent_runtime_state_from_task_record(&retry); - append_game_creator_agent_runtime_task(root, &state) - .expect("persist legacy game-chat art retry task"); - write_game_creator_agent_runtime_state(root, &state) - .expect("persist legacy game-chat art retry state"); - state -} - -#[test] -fn game_chat_dynamic_art_terminal_children_reject_generic_retry_without_durable_side_effects() { - for (case_index, (agent_id, missing_slots, terminal_status)) in [ - ( - "art-director", - vec!["art-spec", "core-spritesheet"], - "failed", - ), - ( - "art-director", - vec!["art-spec", "core-spritesheet"], - "cancelled", - ), - ("art-asset-plan", vec!["core-spritesheet"], "failed"), - ("art-asset-plan", vec!["core-spritesheet"], "cancelled"), - ] - .into_iter() - .enumerate() - { - let temporary = tempfile::tempdir().expect("create terminal art retry root"); - let root = temporary.path().join("project"); - let (_main, mut child, _) = - game_chat_main_art_child_fixture(&root, agent_id, &missing_slots); - child.status = terminal_status.to_string(); - child.phase = terminal_status.to_string(); - child.current_action = "等待用户恢复".to_string(); - child.error = (terminal_status == "failed").then(|| "kind=test-failure".to_string()); - append_game_creator_agent_runtime_task(&root, &child) - .expect("persist terminal game-chat art child task"); - write_game_creator_agent_runtime_state(&root, &child) - .expect("persist terminal game-chat art child state"); - fs::remove_file(game_creator_agent_runtime_task_path( - &root, - "code-prototype", - )) - .expect("remove parent task journal without removing immutable binding lineage"); - fs::remove_file(game_creator_agent_runtime_task_path( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - )) - .expect("remove root task journal without removing immutable binding lineage"); - let terminal_child = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("read terminal game-chat art child") - .expect("terminal game-chat art child exists"); - assert!( - game_chat_dynamic_art_child_structural_identity_at(&root, &terminal_child) - .expect("immutable binding lineage still identifies terminal art child") - ); - let durable_before = snapshot_agent_durable_files(&root); - let retry_run_id = format!("blocked-game-chat-art-retry-{case_index}"); - - let error = retry_game_creator_agent_runtime_task_at( - &root, - &child.agent_id, - &child.run_id, - &retry_run_id, - ) - .expect_err("game-chat dynamic art child retry must be rejected"); - - assert!( - error.contains("kind=game-chat-dynamic-art-retry-unsupported"), - "unexpected typed retry error: {error}" - ); - assert!(error.contains("继续 game-chat 对话"), "{error}"); - assert!(error.contains("下一轮 main code-prototype"), "{error}"); - assert!(error.contains("asset.list"), "{error}"); - assert_eq!( - snapshot_agent_durable_files(&root), - durable_before, - "blocked retry must not persist any successor state: {agent_id}/{terminal_status}" - ); - assert!(read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - &child.agent_id, - &retry_run_id, - ) - .expect("inspect blocked retry successor") - .is_none()); - } -} - -#[tokio::test] -async fn legacy_game_chat_dynamic_art_retry_runs_are_read_only_and_fail_closed() { - let temporary = tempfile::tempdir().expect("create legacy art retry root"); - let root = temporary.path().join("project"); - let (_main, child, _) = - game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); - let retry = - persist_legacy_game_chat_art_retry_run(&root, &child, "legacy-game-chat-art-retry-run"); - let retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - &retry.agent_id, - &retry.run_id, - ) - .expect("read legacy art retry") - .expect("legacy art retry exists"); - assert!( - game_chat_dynamic_art_child_structural_identity_at(&root, &retry_task) - .expect("classify legacy art retry lineage") + &main.agent_id, + &main.run_id, + Some(&pending.action_id), + &action.input, ); - - for read_only_tool in ["asset.list", "file.read", "project.diff", "image.inspect"] { - assert!( - game_chat_delegated_art_agent_input_mutation_block( - &root, - &retry.agent_id, - &retry.run_id, - read_only_tool, - &serde_json::json!({}), - ) - .is_none(), - "diagnostic read-only tool must remain available: {read_only_tool}" - ); - } - - let actions = [ - AgentRuntimeToolAction { - tool: "file.write".to_string(), - reason: None, - input: serde_json::json!({"path":"assets/retry-must-not-write.txt","content":"blocked"}), - }, - AgentRuntimeToolAction { - tool: "project.patchset".to_string(), - reason: None, - input: serde_json::json!({"changes":[{"path":"assets/retry-patchset.txt","operation":"write","content":"blocked"}]}), - }, - AgentRuntimeToolAction { - tool: "canvas.asset_generate".to_string(), - reason: None, - input: serde_json::json!({"outputPath":"assets/art-spritesheet.png","assetKind":"art-spritesheet"}), - }, - AgentRuntimeToolAction { - tool: "memory.write".to_string(), - reason: None, - input: serde_json::json!({"scope":"project","title":"blocked","content":"blocked","mode":"append"}), - }, - AgentRuntimeToolAction { - tool: "task.create".to_string(), - reason: None, - input: serde_json::json!({"taskId":"blocked-retry-task"}), - }, - AgentRuntimeToolAction { - tool: "command.run_limited".to_string(), - reason: None, - input: serde_json::json!({"command":"npm test"}), - }, - AgentRuntimeToolAction { - tool: "preview.validate".to_string(), - reason: None, - input: serde_json::json!({}), - }, - AgentRuntimeToolAction { - tool: "agent.delegate".to_string(), - reason: None, - input: serde_json::json!({"agentId":"art-director","task":"blocked"}), - }, - AgentRuntimeToolAction { - tool: "agent.spawn_isolated".to_string(), - reason: None, - input: serde_json::json!({"children":[]}), - }, - ]; - for action in actions { - let observation = execute_game_creator_agent_runtime_tool_action( - &root, - &retry.agent_id, - &retry.run_id, - &retry.current_task, - &action, - ) - .await; - assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}"); - assert!( - observation.summary.contains("不支持通用 retry"), - "{action:?}: {observation:?}" - ); - } - assert!(game_chat_delegated_art_agent_project_path_mutation_block( - &root, - &retry.agent_id, - &retry.run_id, - "file.write", - "assets/direct-path-bypass.txt", - ) - .is_some()); - assert!(!root.join("assets/retry-must-not-write.txt").exists()); - assert!(!root.join("assets/retry-patchset.txt").exists()); - - fs::remove_file(game_creator_agent_runtime_task_path( - &root, - "code-prototype", - )) - .expect("remove parent task journal for forged legacy fixture"); - let forged_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - &retry.agent_id, - &retry.run_id, - ) - .expect("read forged art retry") - .expect("forged art retry exists"); - assert!( - game_chat_dynamic_art_child_structural_identity_at(&root, &forged_task) - .expect("immutable bindings still prove structural identity") - ); - assert!( - !game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( - &root, - &retry.agent_id, - &retry.run_id, - AGENT_RUNTIME_ART_SPRITESHEET_PATH, - ) - .expect("missing parent task cannot prove strict authorization"), - "retry without a live parent task must not acquire strict authorization" - ); - let forged_action = AgentRuntimeToolAction { - tool: "file.write".to_string(), - reason: None, - input: serde_json::json!({ - "path":"assets/forged-retry-must-not-write.txt", - "content":"blocked", - }), - }; - let forged_observation = execute_game_creator_agent_runtime_tool_action( - &root, - &retry.agent_id, - &retry.run_id, - &retry.current_task, - &forged_action, - ) - .await; - assert_eq!( - forged_observation.status, "blocked", - "{forged_observation:?}" - ); - assert!( - forged_observation.summary.contains("不支持通用 retry"), - "{forged_observation:?}" - ); - assert!( - forged_observation - .detail - .as_deref() - .is_some_and(|detail| detail.contains("下一轮 main code-prototype")), - "{forged_observation:?}" - ); - assert!(!root.join("assets/forged-retry-must-not-write.txt").exists()); -} - -#[test] -fn non_delegated_art_sources_stay_out_of_game_chat_art_gate() { - for source in ["agent-ready-task-scheduler", "agent-background-task"] { - let temporary = tempfile::tempdir().expect("create non-delegated art root"); - let root = temporary.path().join("project"); - init_local_game_project_at(&root, "non-delegated-art", "非委派美术来源") - .expect("init non-delegated art project"); - let run_id = format!("non-delegated-art-{source}"); - start_game_creator_agent_runtime_task_at( - &root, - "art-director", - "非委派来源不得进入 game-chat 美术谱系门", - &run_id, - source, - "校验工具边界", - vec!["非委派来源不受美术门限制".to_string()], - ) - .expect("start non-delegated art runtime"); - for (tool, input) in [ - ( - "command.output_read", - serde_json::json!({"actionId":"history-probe","startLine":1,"maxLines":5}), - ), - ( - "canvas.asset_generate", - serde_json::json!({"prompt":"恢复视觉规范图","outputPath":AGENT_RUNTIME_ART_SPEC_PATH}), - ), - ( - "file.write", - serde_json::json!({"path":"game/notes.txt","content":"scoped by other policies"}), - ), - ] { - assert!( - game_chat_delegated_art_agent_input_mutation_block( - &root, - "art-director", - &run_id, - tool, - &input, - ) - .is_none(), - "{source}/{tool} 不应被 game-chat 美术门拦截" - ); - } - } -} - -#[test] -fn game_chat_dynamic_art_retry_guard_preserves_dag_art_and_non_art_delegated_retry() { - let dag_temporary = tempfile::tempdir().expect("create full DAG art retry root"); - let dag_root = dag_temporary.path().join("project"); - init_local_game_project_at(&dag_root, "full-dag-art-retry", "完整 DAG 美术重试") - .expect("init full DAG art retry project"); - let dag_root_session = resolve_agent_conversation_session_id_at( - &dag_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - None, - true, - ) - .expect("resolve full DAG root session"); - let dag_root_record = append_unique_game_creator_agent_runtime_pending_task( - &dag_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &dag_root_session, - "执行完整任务图", - "full-dag-art-retry-root", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect("queue full DAG root"); - let mut dag_root_state = agent_runtime_state_from_task_record(&dag_root_record); - dag_root_state.status = "running".to_string(); - dag_root_state.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&dag_root, &dag_root_state) - .expect("persist running full DAG root"); - write_game_creator_agent_runtime_state(&dag_root, &dag_root_state) - .expect("persist full DAG root state"); - let dag_manifest = read_manifest_for_project(&dag_root).expect("read full DAG manifest"); - let dag_art_task = dag_manifest - .tasks - .iter() - .find(|task| task.id == "art-director") - .expect("full DAG art-director task"); - let dag_art_session = - resolve_agent_conversation_session_id_at(&dag_root, "art-director", None, true) - .expect("resolve full DAG art session"); - let dag_art_record = append_unique_game_creator_agent_runtime_pending_task( - &dag_root, - "art-director", - &dag_art_session, - &render_autonomous_manifest_ready_task_background_prompt(dag_art_task), - "full-dag-art-failed-run", - "agent-ready-task-scheduler", - None, - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(dag_root_state.agent_id.clone()), - parent_run_id: Some(dag_root_state.run_id.clone()), - delegation_id: None, - }), - ) - .expect("queue full DAG art task"); - let mut dag_art_state = agent_runtime_state_from_task_record(&dag_art_record); - dag_art_state.status = "failed".to_string(); - dag_art_state.phase = "failed".to_string(); - dag_art_state.error = Some("kind=test-failure".to_string()); - append_game_creator_agent_runtime_task(&dag_root, &dag_art_state) - .expect("persist failed full DAG art task"); - write_game_creator_agent_runtime_state(&dag_root, &dag_art_state) - .expect("persist failed full DAG art state"); - let dag_art_lane = try_acquire_game_creator_agent_runtime_task_lock(&dag_root, "art-director") - .expect("acquire full DAG art lane") - .expect("full DAG art lane is free"); - let dag_retry = retry_game_creator_agent_runtime_task_at( - &dag_root, - "art-director", - &dag_art_state.run_id, - "full-dag-art-retry-run", - ) - .expect("full DAG art retry semantics remain available"); - assert_eq!( - dag_retry.accepted_run_id.as_deref(), - Some("full-dag-art-retry-run") - ); - let dag_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &dag_root, - "art-director", - "full-dag-art-retry-run", - ) - .expect("read full DAG art retry") - .expect("full DAG art retry exists"); - assert_eq!(dag_retry_task.source, "agent-delegate-retry"); - assert_eq!( - dag_retry_task.parent_agent_id.as_deref(), - Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - ); - assert!( - !game_chat_dynamic_art_child_structural_identity_at(&dag_root, &dag_retry_task) - .expect("classify full DAG art retry") - ); - assert!(game_chat_delegated_art_agent_input_mutation_block( - &dag_root, - "art-director", - "full-dag-art-retry-run", - "file.write", - &serde_json::json!({"path":AGENT_RUNTIME_ART_SPEC_PATH,"content":"unchanged semantics"}), - ) - .is_none()); - - fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( - &dag_root, - "art-director", - "full-dag-art-retry-run", - )) - .expect("remove full DAG retry binding for unproven-lineage fixture"); - let unproven_block = game_chat_delegated_art_agent_project_path_mutation_block( - &dag_root, - "art-director", - "full-dag-art-retry-run", - "file.write", - "assets/unproven-retry-must-not-write.txt", - ) - .expect("unproven retry-source art lineage must fail closed"); - assert!( - unproven_block.summary.contains("不支持通用 retry"), - "{unproven_block:?}" - ); - drop(dag_art_lane); - - let delegated_temporary = tempfile::tempdir().expect("create non-art delegated retry root"); - let delegated_root = delegated_temporary.path().join("project"); - init_local_game_project_at(&delegated_root, "non-art-delegated-retry", "非美术委派重试") - .expect("init non-art delegated retry project"); - let parent_session = resolve_agent_conversation_session_id_at( - &delegated_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - None, - true, - ) - .expect("resolve non-art parent session"); - let parent_record = append_unique_game_creator_agent_runtime_pending_task( - &delegated_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &parent_session, - "委派非美术任务", - "non-art-delegated-parent", - "agent-background-task", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("queue non-art parent"); - let mut parent_state = agent_runtime_state_from_task_record(&parent_record); - parent_state.status = "running".to_string(); - parent_state.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&delegated_root, &parent_state) - .expect("persist running non-art parent"); - write_game_creator_agent_runtime_state(&delegated_root, &parent_state) - .expect("persist non-art parent state"); - let child_session = - resolve_agent_conversation_session_id_at(&delegated_root, "design-director", None, true) - .expect("resolve non-art child session"); - let child_record = append_unique_game_creator_agent_runtime_pending_task( - &delegated_root, - "design-director", - &child_session, - "完成非美术设计任务", - "non-art-delegated-failed-run", - "agent-delegate", - None, - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(parent_state.agent_id.clone()), - parent_run_id: Some(parent_state.run_id.clone()), - delegation_id: Some("non-art-original-delegation".to_string()), - }), - ) - .expect("queue non-art delegated child"); - let mut child_state = agent_runtime_state_from_task_record(&child_record); - child_state.status = "failed".to_string(); - child_state.phase = "failed".to_string(); - child_state.error = Some("kind=test-failure".to_string()); - append_game_creator_agent_runtime_task(&delegated_root, &child_state) - .expect("persist failed non-art child"); - write_game_creator_agent_runtime_state(&delegated_root, &child_state) - .expect("persist failed non-art child state"); - let non_art_lane = - try_acquire_game_creator_agent_runtime_task_lock(&delegated_root, "design-director") - .expect("acquire non-art child lane") - .expect("non-art child lane is free"); - let delegated_retry = retry_game_creator_agent_runtime_task_at( - &delegated_root, - "design-director", - &child_state.run_id, - "non-art-delegated-retry-run", - ) - .expect("non-art delegated retry semantics remain available"); - assert_eq!( - delegated_retry.accepted_run_id.as_deref(), - Some("non-art-delegated-retry-run") - ); - let delegated_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &delegated_root, - "design-director", - "non-art-delegated-retry-run", - ) - .expect("read non-art delegated retry") - .expect("non-art delegated retry exists"); - assert_eq!(delegated_retry_task.source, "agent-delegate-retry"); - drop(non_art_lane); -} - -#[test] -fn game_chat_failed_art_child_can_only_redelegate_in_the_next_main_round() { - for (case_index, terminal_status) in ["failed", "cancelled"].into_iter().enumerate() { - let temporary = tempfile::tempdir().expect("create cross-round art recovery root"); - let root = temporary.path().join("project"); - init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派") - .expect("pre-initialize cross-round art recovery project"); - let _main_runtime_lane = - try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype") - .expect("acquire cross-round main runtime lane") - .expect("cross-round main runtime lane is free"); - let _art_runtime_lane = - try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan") - .expect("acquire cross-round art runtime lane") - .expect("cross-round art runtime lane is free"); - let (mut first_main, mut terminal_child, first_delegation_id) = - game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); - first_main.status = "running".to_string(); - first_main.phase = "waiting-for-delegate-receipts".to_string(); - first_main.current_action = "等待失败美术 child 回执".to_string(); - append_game_creator_agent_runtime_task(&root, &first_main) - .expect("persist first waiting main task"); - write_game_creator_agent_runtime_state(&root, &first_main) - .expect("persist first waiting main state"); - - terminal_child.status = terminal_status.to_string(); - terminal_child.phase = terminal_status.to_string(); - terminal_child.current_action = "美术 child 未能交付图集".to_string(); - terminal_child.error = - (terminal_status == "failed").then(|| "kind=cross-round-art-child-failure".to_string()); - append_game_creator_agent_runtime_task(&root, &terminal_child) - .expect("persist terminal first-round art child"); - write_game_creator_agent_runtime_state(&root, &terminal_child) - .expect("persist terminal first-round art child state"); - let terminal_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - &terminal_child.agent_id, - &terminal_child.run_id, - ) - .expect("read terminal first-round art child") - .expect("terminal first-round art child exists"); - publish_game_creator_agent_delegate_result( - &root, - &terminal_task, - Some("美术 child 未完成,等待主 Agent 认领失败回执"), - ); - let ready_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id) - .expect("read ready failed art delivery") - .expect("failed art delivery exists"); - assert_eq!(ready_delivery.status, StaticDelegateDeliveryStatus::Ready); - assert_eq!( - ready_delivery.terminal_status.as_deref(), - Some(terminal_status) - ); - let claimed = claim_ready_static_delegate_receipts_at( - &root, - "code-prototype", - &first_main.run_id, - &format!("cross-round-art-claim-{case_index}"), - ) - .expect("claim failed art delivery"); - assert_eq!(claimed.len(), 1); - let claimed_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id) - .expect("read claimed failed art delivery") - .expect("claimed failed art delivery exists"); - assert_eq!( - claimed_delivery.status, - StaticDelegateDeliveryStatus::ClaimedByParent - ); - - let same_run_action_id = format!("same-run-art-redelegate-{case_index}"); - let same_run = observe_agent_runtime_agent_delegate( - &root, - "code-prototype", - &first_main.run_id, - Some(&same_run_action_id), - &serde_json::json!({ - "agentId": "art-asset-plan", - "task": "同一 main run 不得再次尝试同一缺口", - "acceptanceCriteria": ["不应创建第二个 child"], - "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH], - "runId": null - }), - ); - assert_eq!(same_run.status, "failed", "{same_run:?}"); - assert!(same_run.summary.contains("最多委派一次"), "{same_run:?}"); - let rejected_delegation_id = agent_runtime_delegation_id( - "code-prototype", - &first_main.run_id, - "art-asset-plan", - &same_run_action_id, - ); - assert!( - read_static_delegate_delivery_at(&root, &rejected_delegation_id) - .expect("inspect rejected same-run delivery") - .is_none(), - "same-run rejection must not create a second delivery" - ); - - first_main.status = "failed".to_string(); - first_main.phase = "failed".to_string(); - first_main.current_action = "本轮在失败美术回执后结束".to_string(); - append_game_creator_agent_runtime_task(&root, &first_main) - .expect("persist terminal first main task"); - write_game_creator_agent_runtime_state(&root, &first_main) - .expect("persist terminal first main state"); - let first_root_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "game-chat-main-art-root", - ) - .expect("read first game-chat root") - .expect("first game-chat root exists"); - let mut first_root_state = agent_runtime_state_from_task_record(&first_root_task); - first_root_state.status = "failed".to_string(); - first_root_state.phase = "failed".to_string(); - first_root_state.current_action = "等待用户继续 game-chat 对话".to_string(); - append_game_creator_agent_runtime_task(&root, &first_root_state) - .expect("persist terminal first root task"); - write_game_creator_agent_runtime_state(&root, &first_root_state) - .expect("persist terminal first root state"); - - let next_root_run_id = format!("game-chat-cross-round-recovery-root-{case_index}"); - let (next_root, mut next_main) = queue_game_chat_fast_path_child( - &root, - &next_root_run_id, - "继续 game-chat 对话并重新审计仍存在的美术缺口", - "code-prototype", - ); - next_main.status = "running".to_string(); - next_main.phase = "planning".to_string(); - next_main.current_task = "下一轮重新审计并补齐仍存在的美术缺口".to_string(); - next_main.current_action = "重新执行 asset.list 审计".to_string(); - next_main - .recent_tool_calls - .push(AgentRuntimeToolCallRecord { - action_id: Some(format!("next-round-asset-list-{case_index}")), - tool: "asset.list".to_string(), - status: "ok".to_string(), - action_fingerprint: None, - input_summary: None, - reason: Some("下一轮重新审计当前项目资产".to_string()), - summary: "已确认核心图集仍缺失".to_string(), - detail: None, - updated_at: unix_timestamp(), - }); - append_game_creator_agent_runtime_task(&root, &next_main) - .expect("persist next-round main asset audit task"); - write_game_creator_agent_runtime_state(&root, &next_main) - .expect("persist next-round main asset audit state"); - let audited_runtime = read_game_creator_agent_runtime_for_session_at( - &root, - "code-prototype", - Some(&next_main.session_id), - ) - .expect("read next-round audited main runtime") - .state; - assert_eq!(audited_runtime.run_id, next_main.run_id); - persist_game_chat_supervisor_workflow_decision_at( - &root, - &next_root.agent_id, - &next_root.run_id, - GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST, - "下一轮继续补齐仍存在的核心图集缺口", - ) - .expect("persist next-round Supervisor decision"); - let next_coverage = - game_chat_current_asset_coverage_at(&root, &next_root.run_id, &next_main.run_id) - .expect("calculate next-round asset coverage"); - assert_eq!(next_coverage.missing_slots, vec!["core-spritesheet"]); - persist_game_chat_code_asset_route_at( - &root, - &next_main.agent_id, - &next_main.run_id, - GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING, - &next_coverage.missing_slots, - ) - .expect("persist next-round asset route"); - - let next_action_id = format!("next-round-art-delegate-{case_index}"); - let next_round = observe_agent_runtime_agent_delegate( - &root, - "code-prototype", - &next_main.run_id, - Some(&next_action_id), - &serde_json::json!({ - "agentId": "art-asset-plan", - "task": "补齐下一轮审计确认仍缺失的核心图集", - "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"], - "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH], - "runId": null - }), - ); - assert_eq!(next_round.status, "ok", "{next_round:?}"); - let next_delegation_id = agent_runtime_delegation_id( - "code-prototype", - &next_main.run_id, - "art-asset-plan", - &next_action_id, - ); - assert_ne!(next_delegation_id, first_delegation_id); - let next_delivery = read_static_delegate_delivery_at(&root, &next_delegation_id) - .expect("read next-round art delivery") - .expect("next-round art delivery exists"); - let next_child = read_latest_game_creator_agent_runtime_task_by_delegation_id( - &root, - "art-asset-plan", - &next_delegation_id, - ) - .expect("read next-round art child") - .expect("next-round art child exists"); - assert_eq!( - next_delivery.status, - StaticDelegateDeliveryStatus::Dispatched - ); - assert_eq!(next_delivery.parent_run_id, next_main.run_id); - assert_eq!(next_delivery.target_run_id, next_child.run_id); - assert_eq!(next_delivery.target_agent_id, "art-asset-plan"); - assert_eq!( - next_delivery.expected_artifacts, - [AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()] - ); - assert!(next_delivery.terminal_status.is_none()); - - let mut next_child_state = agent_runtime_state_from_task_record(&next_child); - next_child_state.status = "running".to_string(); - next_child_state.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&root, &next_child_state) - .expect("persist running next-round art child"); - write_game_creator_agent_runtime_state(&root, &next_child_state) - .expect("persist running next-round art child state"); - assert!( - game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( - &root, - "art-asset-plan", - &next_child_state.run_id, - AGENT_RUNTIME_ART_SPRITESHEET_PATH, - ) - .expect("verify next-round art lineage") - ); - assert!( - game_chat_delegated_art_agent_project_path_mutation_block( - &root, - "art-asset-plan", - &next_child_state.run_id, - "file.write", - AGENT_RUNTIME_ART_SPRITESHEET_PATH, - ) - .is_none(), - "new parent run and new delivery must restore assets-only authorization" - ); - } + assert_eq!(observation.status, "ok", "{observation:?}"); + mark_supervisor_delivery_claims_observed_for_pending_action_at(root, &pending, &observation) + .expect("mark game-chat parent receipt claim observed"); + (pending, observation) } fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String { @@ -1299,10 +499,7 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre _ => unreachable!(), }; assert_eq!(observation.status, "blocked", "{tool}: {observation:?}"); - assert!( - observation.summary.contains("只能修改 assets/**"), - "{action:?}: {observation:?}" - ); + assert!(observation.summary.contains("只能修改 assets/**")); } let patchset = observe_agent_runtime_project_patchset( @@ -1360,18 +557,6 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre }), }, ] { - let preflight = game_chat_delegated_art_agent_input_mutation_block( - &root, - &child.agent_id, - &child.run_id, - &action.tool, - &action.input, - ) - .unwrap_or_else(|| panic!("delegated art preflight must block {action:?}")); - assert!( - preflight.summary.contains("只能修改 assets/**"), - "{action:?}: {preflight:?}" - ); let observation = execute_game_creator_agent_runtime_tool_action( &root, &child.agent_id, @@ -1381,10 +566,7 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre ) .await; assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}"); - assert!( - observation.summary.contains("只能修改 assets/**"), - "{action:?}: {observation:?}" - ); + assert!(observation.summary.contains("只能修改 assets/**")); } assert_eq!(fs::read(root.join("memory/project.md")).ok(), memory_before); assert_eq!( @@ -1415,324 +597,6 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre ); } -#[test] -fn game_chat_delegated_art_asset_plan_keeps_canvas_verification_and_rejects_borrowed_lineage() { - let temporary = tempfile::tempdir().expect("create game-chat Canvas delivery root"); - let root = temporary.path().join("project"); - init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派") - .expect("pre-initialize game-chat Canvas delivery project"); - let _child_runtime_lane = - try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan") - .expect("acquire game-chat Canvas art child runtime lane") - .expect("game-chat Canvas art child runtime lane is free"); - let (_main, mut child, delegation_id) = - game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); - child.status = "running".to_string(); - child.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&root, &child) - .expect("persist running game-chat art child"); - write_game_creator_agent_runtime_state(&root, &child) - .expect("persist running game-chat art runtime"); - - assert!(!autonomous_owner_artifact_validation_available_for_run_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("evaluate fixed owner validation identity")); - assert!( - game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("validate game-chat delegated art lineage") - ); - - { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.game-chat-canvas-delivery", - ) - .expect("lock game-chat Canvas delivery root"); - prepare_agent_runtime_project_mutation_locked( - &root, - &child.agent_id, - &child.run_id, - "canvas.asset_generate", - ) - .expect("record Canvas mutation"); - let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( - &root, - &child.agent_id, - &child.run_id, - "canvas.asset_generate", - ) - .expect("begin Canvas verification"); - finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) - .expect("finish Canvas verification"); - } - let plan = AgentRuntimeToolPlan { - response: "已按委派生成并登记图集素材。".to_string(), - ..AgentRuntimeToolPlan::default() - }; - let gate = - read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id) - .expect("read Canvas verification gate"); - assert_eq!( - gate.last_verification_tool.as_deref(), - Some("canvas.asset_generate") - ); - validate_agent_runtime_autonomous_specialist_response_delivery( - &root, - &child.agent_id, - &child.run_id, - false, - false, - &gate, - &plan, - ) - .expect("trusted game-chat art child may finalize with Canvas verification"); - - let mut borrowed_project_verify_gate = gate.clone(); - borrowed_project_verify_gate.last_verification_tool = Some("project.verify".to_string()); - write_game_creator_agent_runtime_verification_gate(&root, &borrowed_project_verify_gate) - .expect("persist deliberately borrowed project.verify gate"); - assert!( - validate_agent_runtime_autonomous_specialist_response_delivery( - &root, - &child.agent_id, - &child.run_id, - false, - false, - &borrowed_project_verify_gate, - &plan, - ) - .expect_err("trusted lineage must still require its own Canvas credential") - .contains("只接受本人 canvas.asset_generate") - ); - let borrowed_completion_blocker = - project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]) - .expect("borrowed project.verify must fail the completion gate"); - assert!(borrowed_completion_blocker - .summary - .contains("Canvas 交付凭证")); - write_game_creator_agent_runtime_verification_gate(&root, &gate) - .expect("restore Canvas verification gate"); - let restored_blocker = - project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]); - assert!( - restored_blocker.is_none(), - "restored Canvas credential must satisfy the dynamic art completion gate: {restored_blocker:?}" - ); - assert_eq!( - read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id,) - .expect("reread Canvas verification gate") - .last_verification_tool - .as_deref(), - Some("canvas.asset_generate") - ); - - let delivery = read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read game-chat Canvas delivery") - .expect("game-chat Canvas delivery exists"); - let mut wrong_artifact_delivery = delivery.clone(); - wrong_artifact_delivery.expected_artifacts = vec!["assets/other.png".to_string()]; - write_agent_runtime_json_sidecar( - &root, - &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), - "forged game-chat Canvas delivery", - &wrong_artifact_delivery, - ) - .expect("persist forged game-chat Canvas artifact contract"); - assert!( - !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("reject game-chat Canvas delivery with wrong expected artifact") - ); - write_agent_runtime_json_sidecar( - &root, - &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), - "restored game-chat Canvas delivery", - &delivery, - ) - .expect("restore game-chat Canvas artifact contract"); - - // A task journal and binding that say `agent-delegate` are not sufficient: - // removing the Runtime-issued durable delivery makes the otherwise valid - // game-chat lineage ineligible for the narrow Canvas exception. - fs::remove_file(root.join(format!( - ".agent/runtime/delegation-deliveries/{delegation_id}.json" - ))) - .expect("remove durable delegation proof"); - let forged_asset_path = "assets/forged-without-delivery.txt"; - let forged_asset_write = observe_agent_runtime_file_write( - &root, - &child.agent_id, - &child.run_id, - &AgentRuntimeToolAction { - tool: "file.write".to_string(), - reason: Some("验证伪造 game-chat 美术 lineage 不能取得 assets 写权限".to_string()), - input: serde_json::json!({ - "path": forged_asset_path, - "content": "forged delegated art mutation", - }), - }, - &"0".repeat(64), - None, - ); - assert_eq!( - forged_asset_write.status, "blocked", - "{forged_asset_write:?}" - ); - assert!( - !root.join(forged_asset_path).exists(), - "missing durable delivery must be rejected before assets/** mutation" - ); - assert!( - !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("reject forged delegated source without durable delivery") - ); - let forged_completion_blocker = - project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]) - .expect("forged delegated source must also fail the durable completion gate"); - assert!(forged_completion_blocker - .summary - .contains("不具备可用的验证身份")); - assert!( - validate_agent_runtime_autonomous_specialist_response_delivery( - &root, - &child.agent_id, - &child.run_id, - false, - false, - &gate, - &plan, - ) - .expect_err("forged delegated source must not borrow Canvas finalization") - .contains("固定 owner 的 Runtime 内部产物验证身份不可用") - ); - - write_agent_runtime_json_sidecar( - &root, - &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), - "restored game-chat Canvas delivery before root source forgery", - &delivery, - ) - .expect("restore durable delivery before root source forgery"); - assert!( - game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("restored game-chat Canvas lineage remains valid") - ); - let root_run_id = "game-chat-main-art-root"; - let root_binding = read_game_creator_agent_runtime_run_profile_binding( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - root_run_id, - ) - .expect("read game-chat Canvas root binding") - .expect("game-chat Canvas root binding exists"); - assert_eq!( - root_binding.source, - AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - ); - let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - root_run_id, - ) - .expect("read game-chat Canvas root task") - .expect("game-chat Canvas root task exists"); - let mut wrong_source_root = agent_runtime_state_from_task_record(&root_task); - // Keep the signed binding and route coherent so this reaches the - // task-versus-binding cross-check instead of failing on a broken parent chain. - wrong_source_root.source = AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string(); - append_game_creator_agent_runtime_task(&root, &wrong_source_root) - .expect("persist forged non-game-chat root task"); - assert!( - !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &root, - &child.agent_id, - &child.run_id, - ) - .expect("reject Canvas exception outside a game-chat root") - ); - - let ordinary_temporary = tempfile::tempdir().expect("create ordinary delegated owner root"); - let ordinary_root = ordinary_temporary.path().join("project"); - init_local_game_project_at( - &ordinary_root, - "ordinary-delegated-owner", - "普通委派不能借 game-chat 例外", - ) - .expect("init ordinary delegated owner project"); - let supervisor_session = resolve_agent_conversation_session_id_at( - &ordinary_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - None, - true, - ) - .expect("resolve ordinary Supervisor session"); - let supervisor_record = append_unique_game_creator_agent_runtime_pending_task( - &ordinary_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_session, - "普通 GUI 自主任务", - "ordinary-delegated-root-run", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect("queue ordinary Supervisor"); - let mut supervisor_state = agent_runtime_state_from_task_record(&supervisor_record); - supervisor_state.status = "running".to_string(); - supervisor_state.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&ordinary_root, &supervisor_state) - .expect("persist running ordinary Supervisor"); - let ordinary_session = - resolve_agent_conversation_session_id_at(&ordinary_root, "art-asset-plan", None, true) - .expect("resolve ordinary art session"); - let ordinary_record = append_unique_game_creator_agent_runtime_pending_task( - &ordinary_root, - "art-asset-plan", - &ordinary_session, - "普通 delegated 美术任务", - "ordinary-delegated-art-run", - "agent-delegate", - None, - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(supervisor_state.agent_id.clone()), - parent_run_id: Some(supervisor_state.run_id.clone()), - delegation_id: Some("ordinary-delegation".to_string()), - }), - ) - .expect("queue ordinary delegated art task"); - let mut ordinary_child = agent_runtime_state_from_task_record(&ordinary_record); - ordinary_child.status = "running".to_string(); - ordinary_child.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(&ordinary_root, &ordinary_child) - .expect("persist running ordinary art child"); - assert!( - !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( - &ordinary_root, - &ordinary_child.agent_id, - &ordinary_child.run_id, - ) - .expect("ordinary delegated source is not game-chat lineage") - ); -} - #[test] fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() { let temporary = tempfile::tempdir().expect("create wrong art delegation root"); @@ -2026,6 +890,712 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() { ); } +#[test] +fn game_chat_code_prototype_ignores_suppressed_delivery_without_child_task() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create suppressed delivery recovery root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "game-chat-suppressed-delivery", + "忽略未启动 child 的 suppressed delivery 后重试", + ) + .expect("init suppressed delivery recovery project"); + let (root_state, mut main) = queue_game_chat_fast_path_child( + &root, + "game-chat-suppressed-delivery-root", + "审计已有美术并补齐真实缺口", + "code-prototype", + ); + main.status = "running".to_string(); + main.phase = "planning".to_string(); + main.current_task = "审计现有素材并按真实缺口委派美术".to_string(); + main.current_action = "恢复失败的美术委派".to_string(); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main); + + let suppressed = new_static_delegate_delivery_with_contract( + &main.agent_id, + &main.session_id, + &main.run_id, + "game-chat-suppressed-before-child-action", + "delegation-game-chat-suppressed-before-child", + "art-director", + "game-chat-suppressed-missing-child-session", + "game-chat-suppressed-missing-child-run", + &["只写入受委派的 assets 产物并回执".to_string()], + &[AGENT_RUNTIME_ART_SPEC_PATH.to_string()], + None, + ); + let suppressed = create_or_read_static_delegate_delivery_at(&root, &suppressed) + .expect("create delivery before child startup failure"); + let suppressed = suppress_static_delegate_delivery_at(&root, &suppressed) + .expect("suppress delivery whose child never started"); + assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed); + assert!( + read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + "art-director", + "game-chat-suppressed-missing-child-run", + ) + .expect("check missing suppressed child") + .is_none(), + "fixture must preserve the dispatch-before-child-start failure window" + ); + + assert!( + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id) + .expect("suppressed delivery must not invalidate the trusted parent"), + "trusted game-chat code parent must retain receipt capability" + ); + assert!( + static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id) + .expect("read barrier with suppressed delivery") + .is_clear(), + "suppressed delivery must not block retry or completion" + ); + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("suppressed delivery without child must not fail the fast path"); + + let child_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "art-director") + .expect("acquire retried art child lane") + .expect("retried art child lane available"); + let retry_action_id = "game-chat-retry-after-suppressed-action"; + let retry = observe_agent_runtime_agent_delegate( + &root, + &main.agent_id, + &main.run_id, + Some(retry_action_id), + &serde_json::json!({ + "agentId": "art-director", + "task": "只补齐审计确认缺失的美术资产", + "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"], + "expectedArtifacts": [AGENT_RUNTIME_ART_SPEC_PATH], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(retry.status, "ok", "{retry:?}"); + let retry_delegation_id = agent_runtime_delegation_id( + &main.agent_id, + &main.run_id, + "art-director", + retry_action_id, + ); + let retried_child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &retry_delegation_id, + ) + .expect("read retried art child") + .expect("retried art child exists"); + cancel_game_creator_agent_runtime_task_at( + &root, + &retried_child.agent_id, + &retried_child.run_id, + ) + .expect("cancel retried art child fixture"); + drop(child_lane); +} + +#[test] +fn game_chat_code_prototype_missing_parent_task_with_active_delivery_blocks_completion() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create missing parent task root"); + let root = temporary.path().join("project"); + let (main, child, _delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated child lane is held"); + fs::remove_file(game_creator_agent_runtime_task_path(&root, &main.agent_id)) + .expect("remove code-prototype task journal fixture"); + + let capability = + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id); + assert!( + capability + .as_ref() + .is_err_and(|error| error.contains("存在未收束静态委派")), + "missing parent task with an active delivery must fail closed: {capability:?}" + ); + let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id) + .expect("missing parent task must block completion"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("无法确认当前父 Run")); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel missing-parent child fixture"); + drop(child_lane); +} + +#[test] +fn game_chat_code_prototype_claims_own_failed_art_delivery_and_fails_closed() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create game-chat failed art receipt root"); + let root = temporary.path().join("project"); + let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated art child lane is held"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read dispatched failed-art delivery") + .expect("failed-art delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("External Editor 配置缺失,无法生成平台美术资源"), + ) + .expect("build failed art structured result"); + let ready = mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "External Editor 配置缺失,无法生成平台美术资源", + failed_result, + ) + .expect("mark failed art delivery ready"); + assert_eq!(ready.status, StaticDelegateDeliveryStatus::Ready); + + let deterministic_claim = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("build deterministic failed-art claim plan") + .expect("ready art delivery must be claimed before another Provider request"); + assert_eq!(deterministic_claim.actions.len(), 1); + assert_eq!(deterministic_claim.actions[0].tool, "agent.run_status"); + assert_eq!(deterministic_claim.actions[0].input["scope"], "self"); + + let claim_action = deterministic_claim.actions[0].clone(); + let project_revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read failed-art claim revision"); + let repository_context_fingerprint = build_repository_startup_context_at(&root) + .expect("build failed-art claim repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + &root, + &main, + &main.current_task, + &deterministic_claim, + &[], + &project_revision_before, + &repository_context_fingerprint, + &claim_action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build failed-art run_status pending action"); + let observation = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some(&pending.action_id), + &claim_action.input, + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let receipt_payload = + run_status_prefixed_payload(observation.detail.as_deref(), "readyDelegateReceipts: ") + .expect("parse failed art receipt prefix") + .expect("failed art receipt payload exists"); + let receipt_payload: serde_json::Value = + serde_json::from_str(receipt_payload).expect("parse failed art receipt JSON"); + let receipts = receipt_payload["receipts"] + .as_array() + .expect("failed art receipt array"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["delegationId"], delegation_id); + assert_eq!(receipts[0]["status"], "failed"); + mark_supervisor_delivery_claims_observed_for_pending_action_at(&root, &pending, &observation) + .expect("mark trusted game-chat parent claim observed"); + let barrier = static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id) + .expect("read failed art delivery barrier"); + assert_eq!(barrier.ready_unclaimed_count, 0); + assert_eq!(barrier.unobserved_claim_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + let claimed_contract = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some("game-chat-code-failed-art-contract"), + &serde_json::json!({"delegationId": delegation_id}), + ); + assert_eq!(claimed_contract.status, "ok", "{claimed_contract:?}"); + let contract = claimed_contract + .detail + .as_deref() + .expect("claimed failed art contract detail"); + let contract: serde_json::Value = + serde_json::from_str(contract).expect("parse claimed failed art contract"); + assert_eq!( + contract["claimedDelegateContract"]["terminalStatus"], + "failed" + ); + assert_eq!( + contract["claimedDelegateContract"]["contractStatus"], + "needs-repair" + ); + + for (agent_id, run_id, rejected_delegation_id) in [ + ( + "quality-review", + main.run_id.as_str(), + delegation_id.as_str(), + ), + ( + main.agent_id.as_str(), + "game-chat-code-other-run", + delegation_id.as_str(), + ), + ( + main.agent_id.as_str(), + main.run_id.as_str(), + "delegation-000000000000000000000000", + ), + ] { + let rejected = observe_agent_runtime_run_status( + &root, + agent_id, + run_id, + Some("game-chat-code-cross-scope-contract"), + &serde_json::json!({"delegationId": rejected_delegation_id}), + ); + assert_eq!(rejected.status, "failed", "{rejected:?}"); + assert!(rejected.detail.is_none()); + assert!(rejected.summary.contains("当前可信父 run 或尚未认领")); + } + let unchanged = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read unchanged failed-art delivery") + .expect("failed-art delivery still exists"); + assert_eq!( + unchanged.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + unchanged.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + + let terminal_error = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect_err("non-safe-default failed art delivery must stop before Provider planning"); + assert!(terminal_error.contains("专业美术子任务失败且不能自动返工")); + assert!(terminal_error.contains("art-director")); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel blocked art child fixture"); + drop(child_lane); +} + +#[tokio::test] +async fn game_chat_failed_art_delivery_stops_main_loop_without_provider_request() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create failed art main-loop root"); + let root = temporary.path().join("project"); + let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("failed art child lane is held"); + child.status = "failed".to_string(); + child.phase = "failed".to_string(); + child.current_action = "External Editor 配置缺失,无法生成平台美术资源".to_string(); + child.error = Some(child.current_action.clone()); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist failed art child terminal task"); + write_game_creator_agent_runtime_state(&root, &child) + .expect("persist failed art child terminal runtime"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read main-loop failed art delivery") + .expect("main-loop failed art delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("External Editor 配置缺失,无法生成平台美术资源"), + ) + .expect("build main-loop failed art result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "External Editor 配置缺失,无法生成平台美术资源", + failed_result, + ) + .expect("mark main-loop failed art delivery ready"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + main.agent_id.clone(), + main.current_task.clone(), + main.clone(), + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!( + matches!(outcome, AgentBackgroundTaskOutcome::Finished), + "failed art delivery must terminate the main Run without Provider planning: {outcome:?}" + ); + let terminal = read_game_creator_agent_runtime_for_session_at( + &root, + &main.agent_id, + Some(&main.session_id), + ) + .expect("read terminal failed game-chat main Run") + .state; + assert_eq!(terminal.run_id, main.run_id); + assert_eq!(terminal.status, "failed"); + assert_eq!(terminal.phase, "failed"); + assert!(terminal.error.as_deref().is_some_and(|error| { + error.contains("专业美术子任务失败且不能自动返工") && error.contains("art-director") + })); + let records = + fs::read_to_string(root.join(".agent/agent.db")).expect("read failed art main-loop audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == main.run_id + }) + })); + + drop(child_lane); + crate::tests::wait_for_agent_runtime_terminal_and_lane_release_async( + &root, + &child.agent_id, + &child.run_id, + "failed", + "failed", + ) + .await; +} + +#[test] +fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create tampered art binding root"); + let root = temporary.path().join("project"); + let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated tampered child lane is held"); + let dispatched = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read tampered child delivery") + .expect("tampered child delivery exists"); + let failed_result = build_static_delegate_structured_result_at( + &root, + "failed", + &dispatched.expected_artifacts, + false, + None, + None, + None, + Some("专业美术子任务失败"), + ) + .expect("build tampered child failed result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &child.agent_id, + &child.session_id, + &child.run_id, + &delegation_id, + "failed", + "专业美术子任务失败", + failed_result, + ) + .expect("mark tampered child delivery ready"); + let mut tampered = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read ready delivery before identity tampering") + .expect("ready delivery exists before identity tampering"); + tampered.parent_session_id = "tampered-parent-session".to_string(); + fs::write( + root.join(format!( + ".agent/runtime/delegation-deliveries/{delegation_id}.json" + )), + serde_json::to_vec_pretty(&tampered).expect("serialize tampered delivery fixture"), + ) + .expect("persist tampered delivery fixture"); + + let error = game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect_err("tampered delivery identity must fail before receipt claim"); + assert!(error.contains("delivery 与可信父责任链不一致"), "{error}"); + assert!( + static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id).is_err(), + "tampered delivery must not degrade to an ordinary unauthorized parent" + ); + let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id) + .expect("tampered delivery must block task completion"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("无法确认当前父 Run")); + let claim = observe_agent_runtime_run_status( + &root, + &main.agent_id, + &main.run_id, + Some("game-chat-tampered-delivery-claim"), + &serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": null, + }), + ); + assert_eq!(claim.status, "failed", "{claim:?}"); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read unclaimed tampered delivery") + .expect("unclaimed tampered delivery exists"); + assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Ready); + assert!(delivery.claimed_by_action_id.is_none()); + + cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id) + .expect("cancel tampered child fixture"); + drop(child_lane); +} + +#[test] +fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { + let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); + let root = temporary.path().join("project"); + let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated art child lane is held"); + let needs_input = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "visual_preference", + "header": "视觉偏好", + "question": "请选择首版视觉偏好", + "options": [ + {"label": "明亮", "description": "使用明亮、安全的默认配色。"}, + {"label": "柔和", "description": "使用柔和配色。"} + ] + }] + }) + ); + child.status = "completed".to_string(); + child.phase = "completed".to_string(); + child.current_action = "请求非必要视觉偏好".to_string(); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist completed art child with clarification"); + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("read completed clarification child") + .expect("completed clarification child exists"); + publish_game_creator_agent_delegate_result(&root, &child_task, Some(&needs_input)); + let (_claim_pending, claim_observation) = + claim_game_chat_main_ready_deliveries_with_run_status(&root, &main); + assert!( + claim_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains(&delegation_id)), + "{claim_observation:?}" + ); + let original = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read original safe-default delivery") + .expect("original safe-default delivery exists"); + assert_eq!( + original.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + original + .structured_result + .as_ref() + .map(|result| result.contract_status.clone()), + Some(StaticDelegateContractStatus::NeedsRepair) + ); + assert!(original + .structured_result + .as_ref() + .and_then(|result| result.error.as_deref()) + .is_some_and(|error| error.contains("game-chat-safe-default-repair.v1"))); + let repeated_route_plan = AgentRuntimeToolPlan { + thinking_summary: "重复提交既有资产路由".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: "agent.route_manifest".to_string(), + reason: Some("重复路由不应绕过返工门".to_string()), + input: serde_json::json!({ + "strategy": GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING, + "missingAssetSlots": ["art-spec", "core-spritesheet"], + }), + }], + response: String::new(), + }; + let liveness_error = validate_game_chat_code_safe_default_repair_liveness_at( + &root, + &main.agent_id, + &main.run_id, + &repeated_route_plan, + ) + .expect_err("safe-default repair must reject repeated route/read plans"); + assert!( + liveness_error.contains("必须立即推进唯一安全默认返工"), + "{liveness_error}" + ); + + let deterministic_repair = + game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp()) + .expect("build trusted safe-default repair plan") + .expect("valid safe-default marker must produce a deterministic repair action"); + assert_eq!(deterministic_repair.actions.len(), 1); + assert_eq!(deterministic_repair.actions[0].tool, "agent.delegate"); + assert_eq!( + deterministic_repair.actions[0].input["agentId"].as_str(), + Some(original.target_agent_id.as_str()) + ); + assert_eq!( + deterministic_repair.actions[0].input["repairOfDelegationId"].as_str(), + Some(delegation_id.as_str()) + ); + validate_game_chat_code_safe_default_repair_liveness_at( + &root, + &main.agent_id, + &main.run_id, + &deterministic_repair, + ) + .expect("deterministic same-contract repair must pass the liveness gate"); + + let repair_action_id = "game-chat-safe-default-repair-action"; + let repair = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some(repair_action_id), + &deterministic_repair.actions[0].input, + ); + assert_eq!(repair.status, "ok", "{repair:?}"); + let repair_delegation_id = agent_runtime_delegation_id( + "code-prototype", + &main.run_id, + "art-director", + repair_action_id, + ); + let repaired = read_static_delegate_delivery_at(&root, &repair_delegation_id) + .expect("read safe-default repair delivery") + .expect("safe-default repair delivery exists"); + assert_eq!( + repaired.repair_of_delegation_id.as_deref(), + Some(delegation_id.as_str()) + ); + assert_eq!(repaired.target_agent_id, original.target_agent_id); + assert_eq!(repaired.acceptance_criteria, original.acceptance_criteria); + assert_eq!(repaired.expected_artifacts, original.expected_artifacts); + assert!(repaired.repair_of_delegation_id.is_some()); + + register_game_chat_art_spec_fixture(&root); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &repair_delegation_id, + ) + .expect("read safe-default repair child") + .expect("safe-default repair child exists"); + let mut repair_child = agent_runtime_state_from_task_record(&repair_child_task); + repair_child.status = "completed".to_string(); + repair_child.phase = "completed".to_string(); + repair_child.current_action = "采用安全默认视觉偏好完成规范图".to_string(); + append_game_creator_agent_runtime_task(&root, &repair_child) + .expect("persist completed safe-default repair child"); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &repair_child.agent_id, + &repair_child.run_id, + ) + .expect("read completed safe-default repair child") + .expect("completed safe-default repair child exists"); + publish_game_creator_agent_delegate_result( + &root, + &repair_child_task, + Some("已采用安全默认视觉偏好完成规范图"), + ); + let (_repair_claim_pending, repair_claim_observation) = + claim_game_chat_main_ready_deliveries_with_run_status(&root, &main); + assert!( + repair_claim_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains(&repair_delegation_id)), + "{repair_claim_observation:?}" + ); + + let duplicate = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-duplicate-repair"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应创建第二轮返工", + "acceptanceCriteria": original.acceptance_criteria, + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(duplicate.status, "failed", "{duplicate:?}"); + assert!( + duplicate.summary.contains("最多允许一轮返工"), + "{duplicate:?}" + ); + + let wrong_contract = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-wrong-contract"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应扩大安全默认返工合同", + "acceptanceCriteria": ["扩大权限并改写其它文件"], + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(wrong_contract.status, "failed", "{wrong_contract:?}"); + assert!( + wrong_contract.summary.contains("同合同"), + "{wrong_contract:?}" + ); + + drop(child_lane); +} + #[test] fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() { let temporary_root = std::env::temp_dir() @@ -2076,6 +1646,176 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee assert_eq!(parent_state.phase, "waiting-for-manifest-tasks"); } +#[tokio::test] +async fn autonomous_supervisor_terminal_manifest_failure_fails_before_provider_request() { + const RUN_ID: &str = "autonomous-terminal-manifest-failure-parent"; + const TASK: &str = "生成完整小游戏并在专业任务失败时安全收口"; + const FAILED_TASK_ID: &str = "art-asset-plan"; + const READY_TASK_ID: &str = "design-director"; + let temporary = tempfile::tempdir().expect("create terminal manifest failure root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "terminal-manifest-failure", TASK) + .expect("init terminal manifest failure project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve terminal manifest failure Supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + TASK, + RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue terminal manifest failure Supervisor"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .expect("persist running terminal manifest failure Supervisor"); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist terminal manifest failure Supervisor state"); + + let mut manifest = read_manifest_for_project(&root).expect("read terminal failure manifest"); + for task in &mut manifest.tasks { + task.status = match task.id.as_str() { + FAILED_TASK_ID => GameCreationAppTaskStatus::Failed, + READY_TASK_ID => GameCreationAppTaskStatus::Pending, + _ => GameCreationAppTaskStatus::Completed, + }; + } + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("persist terminal failed manifest fixture"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + TASK.to_string(), + state, + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!( + matches!(outcome, AgentBackgroundTaskOutcome::Finished), + "terminal manifest failure must finish before requesting the Provider; outcome={outcome:?}" + ); + + let failed = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read terminal failed Supervisor") + .state; + assert_eq!(failed.run_id, RUN_ID); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.phase, "failed"); + assert!(failed.error.as_deref().is_some_and(|error| { + error.contains("自主构建 manifest 专业任务失败") && error.contains(FAILED_TASK_ID) + })); + let manifest = read_manifest_for_project(&root).expect("read terminal failed manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == READY_TASK_ID) + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Pending), + "a terminal manifest failure must not start another pending sibling" + ); + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read terminal manifest failure audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == RUN_ID + }) + })); +} + +#[tokio::test] +async fn autonomous_supervisor_waits_for_running_sibling_after_manifest_failure() { + const RUN_ID: &str = "autonomous-manifest-failure-running-sibling-parent"; + const TASK: &str = "生成完整小游戏并等待在途专业任务安全收束"; + const FAILED_TASK_ID: &str = "art-asset-plan"; + const RUNNING_TASK_ID: &str = "audio-asset-plan"; + let temporary = tempfile::tempdir().expect("create failed manifest running sibling root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "manifest-failure-running-sibling", TASK) + .expect("init failed manifest running sibling project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve failed manifest running sibling Supervisor session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + TASK, + RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue failed manifest running sibling Supervisor"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .expect("persist failed manifest running sibling Supervisor"); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist failed manifest running sibling Supervisor state"); + + let mut manifest = read_manifest_for_project(&root).expect("read failed sibling manifest"); + for task in &mut manifest.tasks { + task.status = match task.id.as_str() { + FAILED_TASK_ID => GameCreationAppTaskStatus::Failed, + RUNNING_TASK_ID => GameCreationAppTaskStatus::Running, + _ => GameCreationAppTaskStatus::Completed, + }; + } + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("persist failed manifest with running sibling fixture"); + + let outcome = run_game_creator_agent_background_task_pass_with_context( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + TASK.to_string(), + state, + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!(matches!( + outcome, + AgentBackgroundTaskOutcome::WaitingForManifestTasks + )); + let waiting = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read Supervisor waiting for failed DAG sibling") + .state; + assert_eq!(waiting.run_id, RUN_ID); + assert_eq!(waiting.status, "running"); + assert_eq!(waiting.phase, "waiting-for-manifest-tasks"); + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read failed manifest running sibling audit"); + assert!(!records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == RUN_ID + }) + })); +} + fn prepare_autonomous_completion_evidence( root: &Path, state: &AgentRuntimeState, @@ -2084,41 +1824,6 @@ fn prepare_autonomous_completion_evidence( prepare_autonomous_completion_evidence_for_actor(root, state, state, expect_complete) } -fn start_full_dag_playtest_child_for_completion_fixture( - root: &Path, - parent_state: &AgentRuntimeState, -) -> AgentRuntimeState { - let manifest = read_manifest_for_project(root).expect("read completion fixture manifest"); - let task = manifest - .tasks - .iter() - .find(|task| task.id == "preview-playtest") - .expect("completion fixture preview-playtest task"); - let session_id = resolve_agent_conversation_session_id_at(root, "preview-playtest", None, true) - .expect("resolve completion fixture preview-playtest session"); - let record = append_unique_game_creator_agent_runtime_pending_task( - root, - "preview-playtest", - &session_id, - &render_autonomous_manifest_ready_task_background_prompt(task), - &autonomous_manifest_ready_task_run_id(&parent_state.run_id, "preview-playtest"), - "agent-ready-task-scheduler", - None, - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(parent_state.agent_id.clone()), - parent_run_id: Some(parent_state.run_id.clone()), - delegation_id: None, - }), - ) - .expect("queue completion fixture preview-playtest child"); - let mut state = agent_runtime_state_from_task_record(&record); - state.status = "running".to_string(); - state.phase = "planning".to_string(); - append_game_creator_agent_runtime_task(root, &state) - .expect("persist running completion fixture preview-playtest child"); - state -} - fn prepare_autonomous_completion_evidence_for_actor( root: &Path, contract_state: &AgentRuntimeState, @@ -2129,19 +1834,6 @@ fn prepare_autonomous_completion_evidence_for_actor( read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id) .expect("read autonomous completion contract") .expect("autonomous completion contract exists"); - let root_binding = read_game_creator_agent_runtime_run_profile_binding( - root, - &contract_state.agent_id, - &contract_state.run_id, - ) - .expect("read completion fixture root binding") - .expect("completion fixture root binding exists"); - let full_dag_playtest_state = matches!( - root_binding.source.as_str(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - ) - .then(|| start_full_dag_playtest_child_for_completion_fixture(root, contract_state)); - let playtest_state = full_dag_playtest_state.as_ref().unwrap_or(actor_state); let revision = { let latest = read_latest_game_creator_agent_runtime_task_by_run_id( root, @@ -2237,11 +1929,11 @@ fn prepare_autonomous_completion_evidence_for_actor( let evidence_root = root .join(".agent/runtime/browser-validations") .join(agent_runtime_confirmation_path_component( - &playtest_state.agent_id, + &contract_state.agent_id, "agent", )) .join(agent_runtime_confirmation_path_component( - &playtest_state.run_id, + &contract_state.run_id, "run", )) .join(revision.to_string()); @@ -2339,27 +2031,18 @@ fn prepare_autonomous_completion_evidence_for_actor( input: serde_json::json!({}), }; let action_fingerprint = - agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); - let action_id = - agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task); + let action_id = agent_runtime_tool_action_id(&actor_state.run_id, 1, 0, 1, &action_fingerprint); write_autonomous_playtest_receipt_at( root, &contract, - playtest_state, + actor_state, &action_id, &action_fingerprint, revision, &result, ) .expect("write autonomous playtest receipt"); - if let Some(playtest_state) = full_dag_playtest_state.as_ref() { - let mut completed = playtest_state.clone(); - completed.status = "completed".to_string(); - completed.phase = "completed".to_string(); - completed.current_action = "已完成独立桌面与移动试玩".to_string(); - append_game_creator_agent_runtime_task(root, &completed) - .expect("complete fixture preview-playtest child after receipt"); - } if expect_complete { let blocker = autonomous_game_build_completion_blocker_at_locked(root, actor_state); assert!( @@ -2370,17 +2053,162 @@ fn prepare_autonomous_completion_evidence_for_actor( revision } +fn prepare_autonomous_playtest_evidence_for_actor_at_revision( + root: &Path, + contract_state: &AgentRuntimeState, + actor_state: &AgentRuntimeState, + revision: u64, +) -> BrowserValidationResult { + let contract = + read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id) + .expect("read autonomous completion contract for playtest fixture") + .expect("autonomous completion contract exists for playtest fixture"); + bind_supervisor_collaboration_policy_snapshot_at( + root, + &contract_state.agent_id, + &contract_state.run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind deterministic game-chat collaboration policy"); + + let evidence_root = root + .join(".agent/runtime/browser-validations") + .join(agent_runtime_confirmation_path_component( + &contract_state.agent_id, + "agent", + )) + .join(agent_runtime_confirmation_path_component( + &contract_state.run_id, + "run", + )) + .join(revision.to_string()); + fs::create_dir_all(&evidence_root).expect("create deterministic browser evidence root"); + let screenshots = [ + evidence_root.join("desktop.png"), + evidence_root.join("mobile.png"), + ]; + for screenshot in &screenshots { + fs::write(screenshot, b"\x89PNG\r\n\x1a\nfixture") + .expect("write deterministic browser screenshot fixture"); + } + let viewport = |viewport, screenshot_path| BrowserViewportValidationResult { + viewport, + width: if viewport == BrowserValidationViewport::Desktop { + 1280 + } else { + 390 + }, + height: if viewport == BrowserValidationViewport::Desktop { + 720 + } else { + 844 + }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "自主试玩测试".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "可试玩项目".to_string(), + visible_text_character_count: 5, + dom_character_count: 100, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path, + passed: true, + diagnostics: Vec::new(), + }; + let scenario = contract.playtest_scenario; + let result = BrowserValidationResult { + schema_version: "browser-validation.v1".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "test-browser".to_string(), + protocol_version: "1".to_string(), + }, + passed: true, + viewport_results: vec![ + viewport(BrowserValidationViewport::Desktop, screenshots[0].clone()), + viewport(BrowserValidationViewport::Mobile, screenshots[1].clone()), + ], + playtest: Some(BrowserPlaytestResult { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: true, + initial_sequence: Some(1), + initial_phase: Some(BrowserPlaytestPhase::Ready), + initial_level: Some(1), + final_sequence: Some(8), + final_phase: Some(BrowserPlaytestPhase::Playing), + final_level: Some(2), + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect(), + diagnostics: Vec::new(), + }), + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + fs::write( + &result.evidence.report_path, + serde_json::to_vec_pretty(&result).expect("serialize deterministic browser evidence"), + ) + .expect("write deterministic browser evidence report"); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task); + let action_id = agent_runtime_tool_action_id( + &actor_state.run_id, + actor_state.loop_iteration.max(1), + 3, + 1, + &action_fingerprint, + ); + write_autonomous_playtest_receipt_at( + root, + &contract, + actor_state, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("write deterministic autonomous playtest receipt"); + result +} + #[test] fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_delivery() { let temporary = tempfile::tempdir().expect("create legacy game-chat Tetris root"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "legacy-game-chat-tetris", "水晶俄罗斯方块") .expect("init project"); - let (root_state, mut main_state) = queue_game_chat_fast_path_child( + let (root_state, preview_state) = queue_game_chat_fast_path_child( &root, "legacy-game-chat-tetris-root", "做一个俄罗斯方块,包含旋转、重力下落、锁定和消行", - "code-prototype", + "preview-playtest", ); let mut legacy_contract = read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) @@ -2399,24 +2227,30 @@ fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_deli &legacy_contract, ) .expect("persist legacy Generic Tetris contract"); - prepare_autonomous_completion_evidence_for_actor(&root, &root_state, &main_state, false); - main_state.status = "running".to_string(); - main_state.phase = "planning".to_string(); - persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main_state); + prepare_autonomous_completion_evidence(&root, &root_state, false); - let migrated = autonomous_playtest_execution_contract_for_state_at(&root, &main_state) - .expect("evaluate migrated code-prototype preview execution contract") - .expect("code-prototype inherits the migrated root contract"); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read root binding") + .expect("root binding exists") + .bound_at; + let plan = + game_chat_fast_path_plan_at(&root, &preview_state, &preview_state.current_task, bound_at) + .expect("evaluate migrated preview-playtest fast path") + .expect("preview-playtest emits a deterministic plan"); + assert_eq!(plan.actions.len(), 1, "unexpected plan: {plan:?}"); + assert_eq!(plan.actions[0].tool, "preview.validate"); + let migrated = + read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) + .expect("read migrated root contract") + .expect("migrated root contract exists"); assert_eq!( migrated.playtest_scenario, BrowserPlaytestScenario::TetrisV1 ); - assert!( - read_autonomous_playtest_receipt(&root, &migrated) - .expect("legacy Generic receipt becomes recoverably stale") - .is_none(), - "the Generic receipt must not satisfy the migrated Tetris contract" - ); } #[test] @@ -2432,9 +2266,37 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf assert!(!autonomous_manifest_ready_task_requires_visual_asset( "art-asset-plan" )); + let art_director = new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == "art-director") + .expect("art-director seed task"); + let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director); + let read_only_delivery = + agent_runtime_task_requires_read_only_delivery("art-director", &task_text); + assert!(read_only_delivery); + let forbidden_file_write = AgentRuntimeToolPlan { + actions: vec![AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("无 External Editor API 时不得写入项目".to_string()), + input: serde_json::json!({ + "path": "assets/art-spec.png", + "content": "not-a-canvas-asset" + }), + }], + ..AgentRuntimeToolPlan::default() + }; + let error = validate_agent_runtime_autonomous_read_only_delivery_plan( + "art-director", + read_only_delivery, + &forbidden_file_write, + ) + .expect_err("art-director without External Editor API must remain read-only"); + assert!(error.contains("自主构建只读专业 Agent 禁止执行写入或副作用动作")); } - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-ready-task-user", + "visual-ready-task-test-key", + "https://dev.genarrative.world", ); assert!(autonomous_manifest_ready_task_requires_visual_asset( "art-director" @@ -2448,12 +2310,26 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf assert!(!autonomous_manifest_ready_task_requires_visual_asset( "code-prototype" )); + let art_director = new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == "art-director") + .expect("art-director seed task"); + let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director); + assert!(task_text.contains("非只读视觉规范生成任务")); + assert!(task_text.contains("唯一允许的项目写动作是 canvas.asset_generate")); + assert!(task_text.contains("assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1)")); + assert!(!agent_runtime_task_requires_read_only_delivery( + "art-director", + &task_text + )); } #[test] fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-fast-art-user", + "game-chat-fast-art-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create game-chat art root"); let root = temporary.path().join("project"); @@ -2497,8 +2373,10 @@ fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planni #[test] fn game_chat_art_asset_plan_uses_deterministic_icon_spritesheet_generation() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-fast-spritesheet-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-fast-spritesheet-user", + "game-chat-fast-spritesheet-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create game-chat spritesheet root"); let root = temporary.path().join("project"); @@ -2590,6 +2468,10 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write code verification gate"); child_state.applied_steer_cursor = 2; @@ -2707,7 +2589,11 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { .expect("evaluate repaired code delivery") .expect("fully repaired code may use deterministic delivery"); assert_eq!(playtest.actions.len(), 1); - assert_eq!(playtest.actions[0].tool, "preview.validate"); + assert_eq!(playtest.actions[0].tool, "command.run_limited"); + assert_eq!( + playtest.actions[0].input.get("commandId"), + Some(&serde_json::json!("game.static_smoke")) + ); assert!(playtest.response.is_empty()); } @@ -2744,6 +2630,10 @@ fn game_chat_code_failed_patch_revision_does_not_count_as_owned_mutation() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write failed patch verification gate"); let prior_action = AgentRuntimeToolAction { @@ -2874,6 +2764,10 @@ fn game_chat_code_cannot_borrow_successful_mutation_receipt_from_another_run() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write current verification gate"); let action = AgentRuntimeToolAction { @@ -3403,14 +3297,155 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_ ); } +#[test] +fn game_chat_code_second_loop_waits_for_provider_before_asset_route() { + let temporary = tempfile::tempdir().expect("create unrouted game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-unrouted-code", "制作星河收集小游戏") + .expect("init unrouted game-chat code project"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write unrouted complete art manifest"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-unrouted-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit(&root, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read unrouted game-chat root binding") + .expect("unrouted game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate unrouted second loop") + .is_none(), + "asset.list 后尚未形成资产路由时,第二轮必须交给 Provider 决策与委派" + ); +} + +#[test] +fn game_chat_code_second_loop_waits_for_delegated_visual_contract() { + let temporary = tempfile::tempdir().expect("create routed game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-routed-code", "制作星河收集小游戏") + .expect("init routed game-chat code project"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-routed-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read routed game-chat root binding") + .expect("routed game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate routed second loop without art") + .is_none(), + "资产路由已持久化但美术 child 尚未交付时,第二轮必须继续交给 Provider 执行委派" + ); +} + +#[test] +fn game_chat_code_second_loop_allows_fallback_after_visual_contract_is_ready() { + let temporary = tempfile::tempdir().expect("create ready game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-ready-code", "制作星河收集小游戏") + .expect("init ready game-chat code project"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write ready complete art manifest"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-ready-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + code_state.loop_iteration = 2; + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read ready game-chat root binding") + .expect("ready game-chat root binding exists") + .bound_at; + + let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate ready second loop") + .expect("verified visual contract may use deterministic fallback"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "file.write"); + assert_eq!( + plan.actions[0].input.get("path"), + Some(&serde_json::json!(AGENT_RUNTIME_GAME_INDEX_PATH)) + ); +} + +#[test] +fn game_chat_code_soft_budget_still_fails_closed_without_visual_contract() { + let temporary = tempfile::tempdir().expect("create budgeted game-chat code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-budgeted-code", "制作星河收集小游戏") + .expect("init budgeted game-chat code project"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-budgeted-code-root", + "制作星河收集小游戏", + "code-prototype", + ); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read budgeted game-chat root binding") + .expect("budgeted game-chat root binding exists") + .bound_at; + + let error = game_chat_fast_path_plan_at( + &root, + &code_state, + &code_state.current_task, + bound_at.saturating_add(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS), + ) + .expect_err("soft budget must still fail closed when visual contracts are missing"); + assert!(error.contains("assets/art-spritesheet.png")); +} + #[test] fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_delivery() { let temporary = tempfile::tempdir().expect("create existing game code root"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块") .expect("init existing game project"); - let existing = - ""; + let existing = "

目标:旋转方块消除整行获得胜利;堆满后失败,按 R 重新开始。

"; fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing) .expect("write existing non-placeholder game"); let (root_state, mut code_state) = queue_game_chat_fast_path_child( @@ -3498,6 +3533,584 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del assert_eq!(code_gate.verified_revision, Some(1)); } +#[test] +fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_intervention() { + const ROOT_RUN_ID: &str = "game-chat-deterministic-repair-root"; + const TASK: &str = "生成一版完整可玩的彩虹餐厅经营小游戏,全程自主修复并完成"; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create deterministic game-chat repair root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-deterministic-repair", TASK) + .expect("init deterministic game-chat repair project"); + + let seed_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + seed_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["code-prototype"], + "trusted game-chat must persist one single-main manifest lane" + ); + let (mut root_state, mut code_state) = + queue_game_chat_fast_path_child(&root, ROOT_RUN_ID, TASK, "code-prototype"); + crate::tests::freeze_test_root_goal_contract_at(&root, ROOT_RUN_ID); + assert_eq!(root_state.source, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + root_state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!(code_state.agent_id, "code-prototype"); + assert_eq!(code_state.source, "agent-ready-task-scheduler"); + assert_eq!( + code_state.parent_agent_id.as_deref(), + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + ); + assert_eq!(code_state.parent_run_id.as_deref(), Some(ROOT_RUN_ID)); + assert_eq!( + code_state.run_id, + autonomous_manifest_ready_task_run_id(ROOT_RUN_ID, "code-prototype") + ); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + let art_manifest = read_manifest_for_project(&root).expect("read registered art manifest"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-director") + .expect("deterministic art spec is reusable"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-asset-plan") + .expect("deterministic spritesheet is reusable"); + write_local_project_file_at( + &root, + "assets/manifest.art.json", + &game_chat_fast_path_art_manifest_content(), + ) + .expect("write reusable deterministic art manifest before audit"); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + code_state.loop_iteration = 1; + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("persist running deterministic code child"); + write_game_creator_agent_runtime_state(&root, &code_state) + .expect("persist deterministic code runtime"); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + + let playable_html = render_game_chat_fast_path_html("彩虹餐厅经营挑战"); + let invalid_html = playable_html.replacen( + " stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + " function terminalRepairProbe() {} terminalRepairProbe()const terminalPhases = ['won', 'lost'];\n stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + 1, + ); + assert_ne!( + invalid_html, playable_html, + "invalid JavaScript fixture marker" + ); + assert!(invalid_html.contains("terminalRepairProbe()const terminalPhases")); + let initial_write = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("生成首版可试玩游戏入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": invalid_html, + }), + }; + let initial_write_fingerprint = + agent_runtime_tool_action_fingerprint(&initial_write, &code_state.current_task); + let initial_write_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &initial_write_fingerprint, + ); + let initial_write_observation = observe_agent_runtime_file_write( + &root, + &code_state.agent_id, + &code_state.run_id, + &initial_write, + &initial_write_fingerprint, + None, + ); + assert_eq!(initial_write_observation.status, "ok"); + let initial_write_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &initial_write_task, + &initial_write, + &initial_write_observation, + Some(&initial_write_id), + Some(&initial_write_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &initial_write_id, + &initial_write_fingerprint, + "file.write", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &initial_write_observation, + ) + .expect("persist initial game write receipt"); + + let failed_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("检查首版游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let failed_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&failed_smoke, &code_state.current_task); + let failed_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &failed_smoke_fingerprint, + ); + let failed_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &failed_smoke.input, + ); + assert_eq!(failed_smoke_observation.status, "failed"); + let failed_smoke_detail = failed_smoke_observation + .detail + .as_deref() + .expect("failed smoke diagnostic"); + assert!( + failed_smoke_detail.contains("不是有效 JavaScript"), + "{failed_smoke_detail}" + ); + assert!( + !failed_smoke_detail.contains("可渲染画布"), + "JavaScript syntax must fail before surface checks: {failed_smoke_detail}" + ); + let failed_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &failed_smoke_task, + &failed_smoke, + &failed_smoke_observation, + Some(&failed_smoke_id), + Some(&failed_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &failed_smoke_id, + &failed_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &failed_smoke_observation, + ) + .expect("persist failed static smoke receipt"); + + update_agent_runtime_plan_steps( + &mut code_state, + vec!["记录开发者投递的后台任务".to_string()], + ); + activate_agent_runtime_plan_step(&mut code_state, 0, "执行 game.static_smoke"); + complete_agent_runtime_active_plan_step( + &mut code_state, + "failed", + &failed_smoke_observation.summary(), + ); + assert_eq!(code_state.plan_revision, 0); + assert_eq!( + code_state.plan_steps[0].status, + AGENT_RUNTIME_PLAN_STATUS_FAILED + ); + + apply_agent_runtime_plan_update( + &mut code_state, + &AgentRuntimePlanUpdate { + explanation: "首版 smoke 已返回可修复诊断,保持同一主 Run 继续修复与复验".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "完成资产审计与缺口路由".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "写入可运行游戏 scaffold".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "修复静态 smoke 阻断".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "重新执行静态 smoke".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "完成浏览器试玩与视觉检查".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "向父 Agent 交付证据回执".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + }, + ], + }, + ) + .expect("replace failed legacy scaffold with first structured repair plan"); + assert_eq!(code_state.plan_revision, 1); + assert!(code_state + .plan_steps + .iter() + .all(|step| step.status != AGENT_RUNTIME_PLAN_STATUS_FAILED)); + assert!(code_state + .plan_steps + .iter() + .all(|step| step.title != "记录开发者投递的后台任务")); + + let (history, count, truncated, output_truncated) = read_agent_runtime_action_history( + &root, + &code_state.agent_id, + &code_state.run_id, + &serde_json::json!({ + "runId": code_state.run_id, + "actionId": failed_smoke_id, + "tool": "command.run_limited", + "status": "failed", + "limit": 1, + }), + ) + .expect("read owner-visible failed smoke action history"); + assert_eq!(count, 1); + assert!(!truncated); + assert!(!output_truncated); + let history: serde_json::Value = + serde_json::from_str(&history).expect("parse failed smoke history"); + let history_item = &history["actions"][0]; + assert_eq!(history_item["actionId"], failed_smoke_id); + let diagnostic: serde_json::Value = serde_json::from_str( + history_item["safeDetail"] + .as_str() + .expect("owner-visible structured smoke detail"), + ) + .expect("parse owner-visible structured smoke detail"); + assert_eq!(diagnostic["failureCode"], "static-smoke-failed"); + assert_eq!(diagnostic["check"], "javascript-syntax"); + assert_eq!(diagnostic["path"], AGENT_RUNTIME_GAME_INDEX_PATH); + assert!(diagnostic["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + + code_state.loop_iteration = 2; + let repair = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("根据结构化 JavaScript 语法诊断修复当前入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "terminalRepairProbe()const terminalPhases", + "newText": "terminalRepairProbe();const terminalPhases", + "expectedReplacements": 1, + }), + }; + let repair_fingerprint = + agent_runtime_tool_action_fingerprint(&repair, &code_state.current_task); + let repair_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &repair_fingerprint, + ); + let repair_observation = observe_agent_runtime_file_patch( + &root, + &code_state.agent_id, + &code_state.run_id, + &repair, + &repair_fingerprint, + None, + ); + assert_eq!(repair_observation.status, "ok", "{repair_observation:?}"); + let repair_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repair_task, + &repair, + &repair_observation, + Some(&repair_id), + Some(&repair_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repair_id, + &repair_fingerprint, + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repair_observation, + ) + .expect("persist same-run repair receipt"); + + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read deterministic game-chat root binding") + .expect("deterministic game-chat root binding exists") + .bound_at; + let automatic_recheck = + game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate repaired current revision") + .expect("same-run repair must automatically recheck current revision"); + assert_eq!(automatic_recheck.actions.len(), 1); + assert_eq!(automatic_recheck.actions[0].tool, "command.run_limited"); + assert_eq!( + automatic_recheck.actions[0].input.get("commandId"), + Some(&serde_json::json!("game.static_smoke")) + ); + assert!(automatic_recheck.plan_update.is_none()); + assert!(automatic_recheck.response.is_empty()); + + let repaired_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("重新验证已修复的当前 revision".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let repaired_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&repaired_smoke, &code_state.current_task); + let repaired_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &repaired_smoke_fingerprint, + ); + let repaired_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &repaired_smoke.input, + ); + assert_eq!( + repaired_smoke_observation.status, "ok", + "{repaired_smoke_observation:?}" + ); + let repaired_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repaired_smoke_task, + &repaired_smoke, + &repaired_smoke_observation, + Some(&repaired_smoke_id), + Some(&repaired_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repaired_smoke_id, + &repaired_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repaired_smoke_observation, + ) + .expect("persist repaired static smoke receipt"); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read repaired project revision") + .revision; + let code_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &code_state.agent_id, + &code_state.run_id, + ) + .expect("read repaired code verification gate"); + assert_eq!(code_gate.mutation_revision, Some(revision)); + assert_eq!(code_gate.verified_revision, Some(revision)); + assert_eq!(code_gate.static_smoke_verified_revision, Some(revision)); + assert_eq!(code_gate.last_mutation_tool.as_deref(), Some("file.patch")); + + for (path, content) in [ + ("memory/project.md", "# 项目记忆\n\n正式约束。\n"), + ("game/game_design.md", "# 游戏设计\n\n核心循环。\n"), + ("game/balance.json", r#"{"lives":3,"speed":1}"#), + ("assets/manifest.audio.json", r#"{"bgm":[],"sfx":[]}"#), + ("exports/README.md", "# 发布说明\n\n可试玩。\n"), + ] { + write_local_project_file_at(&root, path, content) + .unwrap_or_else(|error| panic!("write deterministic artifact {path}: {error}")); + } + let browser_result = prepare_autonomous_playtest_evidence_for_actor_at_revision( + &root, + &root_state, + &code_state, + revision, + ); + assert!(browser_result.passed); + assert_eq!(browser_result.viewport_results.len(), 2); + assert!(browser_result + .viewport_results + .iter() + .any( + |viewport| viewport.viewport == BrowserValidationViewport::Desktop && viewport.passed + )); + assert!(browser_result + .viewport_results + .iter() + .any(|viewport| viewport.viewport == BrowserValidationViewport::Mobile && viewport.passed)); + let code_completion_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state); + assert!( + code_completion_blocker.is_none(), + "deterministic code completion blocker: {code_completion_blocker:?}" + ); + + let validated_game_index = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)).expect("snapshot validated game entry"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("mutate game entry without advancing durable revision"); + let stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("same-revision stale playtest receipt must block completion"); + assert!( + stale_receipt_blocker.summary.contains("game.static_smoke"), + "unexpected stale receipt blocker: {stale_receipt_blocker:?}" + ); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + validated_game_index, + ) + .expect("restore validated game entry"); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), + "restored validated entry must satisfy completion gate" + ); + + let completed_code = finish_game_creator_agent_runtime_turn_at( + &root, + code_state, + &format!("首版已完成,并通过 revision {revision} 的静态检查与双视口试玩。"), + ) + .expect("finish deterministic code-prototype run"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore running manifest projection before deterministic terminal projection"); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &completed_code) + .expect("project deterministic code-prototype completion") + ); + let root_stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &root_state); + assert!( + root_stale_receipt_blocker.is_none(), + "root must accept the restored current entry: {root_stale_receipt_blocker:?}" + ); + crate::tests::pass_test_root_acceptance_graph_at(&root, &root_state); + let mut provider_plan = AgentRuntimeToolPlan { + actions: vec![AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("模型原本仍想继续轮询".to_string()), + input: serde_json::json!({}), + }], + ..AgentRuntimeToolPlan::default() + }; + let convergence = prepare_game_chat_single_round_convergence_at( + &root, + &mut root_state, + &mut provider_plan, + unix_timestamp(), + ) + .expect("prepare deterministic root convergence") + .expect("fully verified game-chat root must converge without another Provider plan"); + assert_eq!(convergence.1, revision); + assert!(provider_plan.actions.is_empty()); + let completed_root = + finish_game_creator_agent_runtime_turn_at(&root, root_state, &convergence.0) + .expect("finish deterministic game-chat root once"); + assert_eq!(completed_root.phase, "completed"); + + let logical_code_runs = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, + "code-prototype", + )) + .expect("read deterministic code journal"), + ) + .into_iter() + .filter(|record| { + record.source == "agent-ready-task-scheduler" + && record.parent_run_id.as_deref() == Some(ROOT_RUN_ID) + }) + .collect::>(); + assert_eq!(logical_code_runs.len(), 1); + assert_eq!(logical_code_runs[0].run_id, completed_code.run_id); + assert_eq!(logical_code_runs[0].phase, "completed"); + let root_terminal_records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + ) + .expect("read deterministic root journal") + .into_iter() + .filter(|record| record.run_id == ROOT_RUN_ID && record.phase == "completed") + .count(); + assert_eq!( + root_terminal_records, 1, + "root must have one completed terminal" + ); + let (db_records, _) = + read_agent_db_records_bounded(&root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES) + .expect("read deterministic Agent DB records"); + assert_eq!( + db_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.completed") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(ROOT_RUN_ID) + }) + .count(), + 1, + "root completion audit must be unique" + ); + for agent_id in [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "code-prototype"] { + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, agent_id), + ) + .expect("read no-intervention journal"); + assert!(records.iter().all(|record| { + record.status != "waiting-for-confirmation" + && record.status != "waiting-for-user-input" + && record.phase != "waiting-for-confirmation" + && record.phase != "waiting-for-user-input" + })); + } + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_code.agent_id, + &completed_code.run_id, + ) + .exists()); + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_root.agent_id, + &completed_root.run_id, + ) + .exists()); + let confirmations_root = root.join(".agent/runtime/confirmations"); + assert!( + !confirmations_root.exists() + || fs::read_dir(&confirmations_root) + .expect("read confirmation root") + .next() + .is_none(), + "deterministic game-chat completion must not require confirmation" + ); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( @@ -3898,8 +4511,10 @@ fn game_chat_scheduler_recovers_deterministic_active_code_prototype_with_v1_task const PARENT_RUN_ID: &str = "game-chat-v1-ready-text-recovery-parent"; const PARENT_TASK: &str = "继续把已有美术接入俄罗斯方块"; const MAIN_AGENT_ID: &str = "code-prototype"; - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"game-chat-v1-task-recovery-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "game-chat-v1-task-recovery-user", + "game-chat-v1-task-recovery-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create v1 task recovery root"); let root = temporary.path().join("project"); @@ -4390,8 +5005,10 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent"; const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物"; const CHILD_ID: &str = "art-director"; - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-recovery-user", + "visual-recovery-test-key", + "https://dev.genarrative.world", ); let temporary = tempfile::tempdir().expect("create visual recovery root"); let root = temporary.path().join("project"); @@ -4629,7 +5246,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) .expect("read autonomous Supervisor runtime") .state; - for _ in 0..1_500 { + for _ in 0..500 { if runtime.status == "idle" || runtime.status == "failed" { break; } @@ -4642,11 +5259,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac let fallback = format!( "项目已完成生成,并通过当前 revision {revision} 的静态检查和桌面、移动端交互试玩验证。" ); - assert_eq!( - runtime.status, "idle", - "phase={}, currentAction={}, waitingOn={}, error={:?}", - runtime.phase, runtime.current_action, runtime.waiting_on, runtime.error - ); + assert_eq!(runtime.status, "idle"); assert_eq!(runtime.phase, "completed"); assert_eq!(runtime.last_response.as_deref(), Some(fallback.as_str())); @@ -4766,311 +5379,3 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac .chunks_exact(2) .all(|pair| pair == ["started", "failed"])); } - -fn planning_submit_completion_fixture_at( - root: &Path, -) -> ( - AgentRuntimeState, - AgentRuntimePendingToolAction, - PlanSubmitGddResultV1, - String, -) { - init_local_game_project_at(root, "planning-submit-completion", "策划提交终态恢复") - .expect("initialize planning submit completion project"); - let parent_run_id = "planning-submit-completion-parent-run"; - start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "收敛 Fast GDD", - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "准备委派立项策划 Agent", - vec!["委派 project-planning".to_string()], - ) - .expect("start planning Supervisor root"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning Supervisor root"); - - let planning_lane = try_acquire_game_creator_agent_runtime_task_lock( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ) - .expect("acquire planning child lane") - .expect("planning child lane is available"); - let delegate_action_id = "action-111111111111111111111111"; - let observation = observe_agent_runtime_agent_delegate( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some(delegate_action_id), - &serde_json::json!({ - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "task": "输出可审批的 Fast GDD", - "acceptanceCriteria": ["提交 strict Fast GDD"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(observation.status, "ok", "{observation:?}"); - let delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - delegate_action_id, - ); - let child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &delegation_id, - ) - .expect("read planning child task") - .expect("planning child task exists"); - drop(planning_lane); - - let mut runtime = agent_runtime_state_from_task_record(&child_task); - runtime.loop_iteration = 1; - let action = AgentRuntimeToolAction { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - reason: Some("提交已校验的 Fast GDD".to_string()), - input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}), - }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); - let occurrence_nonce = 42; - let action_id = agent_runtime_tool_action_id( - &runtime.run_id, - runtime.loop_iteration, - 0, - occurrence_nonce, - &action_fingerprint, - ); - let now = unix_timestamp(); - let pending = AgentRuntimePendingToolAction { - schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), - fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - planning_session_binding: None, - provider_batch_plan_update: None, - task: runtime.current_task.clone(), - goal_id: runtime.goal_id.clone(), - goal_revision: runtime.goal_revision, - goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, &runtime) - .expect("read planning child goal snapshot"), - loop_iteration: runtime.loop_iteration, - action_index: 0, - occurrence_nonce, - thinking_summary: "Fast GDD 已通过 strict 校验".to_string(), - plan: vec!["提交 Fast GDD".to_string()], - fallback_response: String::new(), - observations: Vec::new(), - project_revision_before: read_game_creator_agent_runtime_project_revision(root) - .expect("read planning submit project revision"), - verification_gate_before: read_game_creator_agent_runtime_verification_gate( - root, - &runtime.agent_id, - &runtime.run_id, - ) - .expect("read planning submit verification gate"), - planned_repository_context_fingerprint: build_repository_startup_context_at(root) - .expect("read planning submit repository context") - .fingerprint, - planned_steer_cursor: runtime.applied_steer_cursor, - action, - action_id: action_id.clone(), - action_fingerprint, - input_summary: None, - execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(), - status: AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(), - observation: None, - created_at: now, - updated_at: now, - }; - let result = PlanSubmitGddResultV1 { - outcome: "submitted".to_string(), - gdd_ref: PlanGddRef { - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - version: 1, - fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), - }, - pending_action_id: action_id, - approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), - recovery_pending: false, - }; - (runtime, pending, result, delegation_id) -} - -fn read_planning_submit_completion_jsonl(path: &Path) -> Vec { - fs::read_to_string(path) - .ok() - .into_iter() - .flat_map(|content| { - content - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| { - serde_json::from_str::(line) - .expect("parse planning submit completion JSONL") - }) - .collect::>() - }) - .collect() -} - -fn planning_submit_completion_audit_count( - root: &Path, - record_type: &str, - action_id: &str, -) -> usize { - read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) - .into_iter() - .filter(|record| { - record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) - && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) - }) - .count() -} - -#[test] -fn planning_submit_child_completion_is_action_scoped_and_idempotent() { - let temporary = tempfile::tempdir().expect("create planning submit completion root"); - let root = temporary.path().join("project"); - let (mut runtime, pending, result, delegation_id) = - planning_submit_completion_fixture_at(&root); - - ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) - .expect("complete planning child first time"); - ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) - .expect("replay planning child completion"); - - let task_projection_count = read_planning_submit_completion_jsonl( - &game_creator_agent_runtime_task_path(&root, &runtime.agent_id), - ) - .into_iter() - .filter(|record| { - record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str()) - && record.get("actionId").and_then(serde_json::Value::as_str) - == Some(pending.action_id.as_str()) - && record.get("phase").and_then(serde_json::Value::as_str) == Some("completed") - }) - .count(); - assert_eq!(task_projection_count, 1); - - let action_event_count = read_planning_submit_completion_jsonl( - &game_creator_agent_runtime_event_path(&root, &runtime.agent_id), - ) - .into_iter() - .filter(|record| { - record.get("eventType").and_then(serde_json::Value::as_str) - == Some("plan.submit_gdd.committed") - && record.get("actionId").and_then(serde_json::Value::as_str) - == Some(pending.action_id.as_str()) - }) - .count(); - assert_eq!(action_event_count, 1); - - let matching_deliveries = list_static_delegate_deliveries_at(&root) - .expect("list planning child deliveries") - .into_iter() - .filter(|delivery| delivery.delegation_id == delegation_id) - .collect::>(); - assert_eq!(matching_deliveries.len(), 1); - assert_eq!( - matching_deliveries[0].status, - StaticDelegateDeliveryStatus::Ready - ); - assert_eq!( - matching_deliveries[0].terminal_status.as_deref(), - Some("completed") - ); - assert_eq!( - planning_submit_completion_audit_count( - &root, - "agent.runtime.agent.delegate_receipt.ready", - "action-111111111111111111111111", - ), - 1 - ); - assert_eq!( - planning_submit_completion_audit_count( - &root, - "agent.runtime.plan_submit_gdd.committed", - &pending.action_id, - ), - 1 - ); -} - -#[test] -fn planning_submit_child_completion_waits_for_exact_delivery_before_committed_audit() { - let temporary = tempfile::tempdir().expect("create planning submit delivery recovery root"); - let root = temporary.path().join("project"); - let (mut runtime, pending, result, delegation_id) = - planning_submit_completion_fixture_at(&root); - let delivery = read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read dispatched planning delivery") - .expect("dispatched planning delivery exists"); - fs::remove_file( - root.join(".agent/runtime/delegation-deliveries") - .join(format!("{delegation_id}.json")), - ) - .expect("remove planning delivery to model post-commit interruption"); - - let error = - ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) - .expect_err("completion must wait for exact durable delivery"); - assert!(error.contains("durable delivery 不存在"), "{error}"); - assert_eq!( - planning_submit_completion_audit_count( - &root, - "agent.runtime.plan_submit_gdd.committed", - &pending.action_id, - ), - 0, - "delivery 未 durable 前不得写 recoveryPending=false committed audit" - ); - - create_or_read_static_delegate_delivery_at(&root, &delivery) - .expect("restore exact dispatched planning delivery"); - ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) - .expect("resume same planning submit action after delivery repair"); - let recovered_delivery = read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read recovered planning delivery") - .expect("recovered planning delivery exists"); - assert_eq!( - recovered_delivery.status, - StaticDelegateDeliveryStatus::Ready - ); - assert_eq!( - recovered_delivery.terminal_status.as_deref(), - Some("completed") - ); - let committed = read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) - .into_iter() - .filter(|record| { - record.get("recordType").and_then(serde_json::Value::as_str) - == Some("agent.runtime.plan_submit_gdd.committed") - && record.get("actionId").and_then(serde_json::Value::as_str) - == Some(pending.action_id.as_str()) - }) - .collect::>(); - assert_eq!(committed.len(), 1); - assert_eq!( - committed[0] - .get("recoveryPending") - .and_then(serde_json::Value::as_bool), - Some(false) - ); -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 9180657d5..865ef68d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -346,6 +346,24 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at_locked( if !project_lock.guards_project_root(root)? { return Err("Supervisor 澄清 pending 投影缺少当前项目写锁".to_string()); } + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .ok_or_else(|| "needs-user-input 转换缺少当前 parent task".to_string())?; + if parent_task.session_id != runtime.session_id + || parent_task.source != runtime.source + || parent_task.run_profile != runtime.run_profile + || parent_task.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + { + return Err("needs-user-input 转换的 parent task 与 runtime 身份不一致".to_string()); + } + if convert_claimed_game_chat_user_input_deliveries_to_repair_at(root, &parent_task, deliveries)? + > 0 + { + return Ok(false); + } let mut pending_deliveries = deliveries.iter().filter(|delivery| { delivery.structured_result.as_ref().is_some_and(|result| { result.contract_status == StaticDelegateContractStatus::NeedsUserInput diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9fb62c210..91ff9e77f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1610,14 +1610,8 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let verification_requirement = match task.id.as_str() { - "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", - task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", - "publish-package" => "publish-package 不在前置固定 owner 的内部产物验证范围内;必须继续按现有发布完成合同和当前 run 的可用验证门收束,不得借用前置 owner 的验证凭证。", - _ => "完成修改后按当前任务的既有验证合同收束。", - }; format!( - "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。{verification_requirement}不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ) } @@ -1647,14 +1641,9 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( "{base}\n\n这是 autonomous-game-build 的只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); } - if task.id == "art-director" { - if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { - return format!( - "{base}\n\n这是 autonomous-game-build 的条件 Canvas owner 任务。当前已配置 External Editor 生图凭据,必须按角色固定合同生成并登记 assets/art-spec.png。canvas.asset_generate 成功只表示候选已生成并登记,不等于视觉门已经通过;完成后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。项目 mutation 仅限固定 Canvas 产物;不得调用 game.static_smoke、project.verify、command.run_limited、preview 或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" - ); - } + if task.id == "art-director" && editor_api_key_is_configured() { return format!( - "{base}\n\n这是 autonomous-game-build 的无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); } if autonomous_manifest_task_requires_project_mutation(&task.id) { @@ -1701,89 +1690,3 @@ pub(in crate::agent) fn render_manifest_ready_task_background_prompt( acceptance ) } - -#[cfg(test)] -mod tests { - use super::*; - - fn seed_task(task_id: &str) -> GameCreationAppTaskState { - new_game_creation_app_seed_tasks() - .into_iter() - .find(|task| task.id == task_id) - .unwrap_or_else(|| panic!("missing seed task {task_id}")) - } - - #[test] - fn autonomous_ready_task_prompts_separate_owner_artifacts_from_playable_validation() { - let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - - for task_id in [ - "design-foundation", - "balance-seed", - "art-asset-plan", - "audio-asset-plan", - ] { - let prompt = - render_autonomous_manifest_ready_task_background_prompt(&seed_task(task_id)); - assert!(prompt.contains("由 Runtime 在收束门内验证本人固定 owner 产物")); - assert!( - prompt.contains("禁止调用 game.static_smoke、project.verify、command.run_limited") - ); - } - - let code_prompt = - render_autonomous_manifest_ready_task_background_prompt(&seed_task("code-prototype")); - assert!(code_prompt.contains("必须对可玩入口执行 game.static_smoke")); - assert!(!code_prompt.contains("验证本人固定 owner 产物")); - - let readiness_prompt = render_autonomous_manifest_ready_task_background_prompt(&seed_task( - "preview-readiness", - )); - assert!( - readiness_prompt.contains("且只能是 command.run_limited(commandId=game.static_smoke)") - ); - assert!(!readiness_prompt.contains("preview.validate")); - - let publish_prompt = - render_autonomous_manifest_ready_task_background_prompt(&seed_task("publish-package")); - assert!(publish_prompt.contains("不在前置固定 owner 的内部产物验证范围内")); - assert!(!publish_prompt.contains("验证本人固定 owner 产物")); - } - - #[test] - fn art_director_ready_task_is_read_only_without_key_and_canvas_owner_with_key() { - let task = seed_task("art-director"); - { - let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - let prompt = render_autonomous_manifest_ready_task_background_prompt(&task); - assert!(prompt.contains("无生图凭据只读协调任务")); - assert!(prompt.contains("assets/art-spec.png 图片产物与生成验收条款在本轮不适用")); - assert!(prompt.contains("不要修改项目文件")); - assert!(!autonomous_manifest_ready_task_requires_visual_asset( - "art-director" - )); - assert!(agent_runtime_task_requires_read_only_delivery( - "art-director", - &prompt - )); - } - { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"art-director-ready-task-key"}}"#.to_string(), - ); - let prompt = render_autonomous_manifest_ready_task_background_prompt(&task); - assert!(prompt.contains("条件 Canvas owner 任务")); - assert!(prompt.contains("生成并登记 assets/art-spec.png")); - assert!(prompt.contains("成功只表示候选已生成并登记,不等于视觉门已经通过")); - assert!(prompt.contains("由 Runtime 在收束时核对当前 revision")); - assert!(!prompt.contains("成功会为本人当前 revision 形成验证凭证")); - assert!(autonomous_manifest_ready_task_requires_visual_asset( - "art-director" - )); - assert!(!agent_runtime_task_requires_read_only_delivery( - "art-director", - &prompt - )); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 9249a7998..580fcf120 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -688,8 +688,13 @@ fn rendered_html_markup_and_stylesheets(content: &str) -> (String, String) { .unwrap_or(lower.len()); continue; } + if !html_tag_like_start(&lower, tag_start) { + cursor = tag_start + 1; + continue; + } let Some(tag_after) = html_tag_end(&lower, tag_start) else { - break; + cursor = tag_start + 1; + continue; }; let tag_end = tag_after - 1; let tag = &lower[tag_start..=tag_end]; @@ -6319,7 +6324,21 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + let static_smoke_matches_current_entry = + match agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + ) { + Ok(matches) => matches, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法复核当前 game.static_smoke 入口摘要", + error, + )); + } + }; + if !static_smoke_matches_current_entry { return Some(autonomous_completion_blocker( "preview-readiness 尚未通过当前 revision 的 game.static_smoke", format!( @@ -6372,9 +6391,29 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + let current_index = match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "preview-playtest 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, _))) => digest, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口缺失", + "必须重新生成 game/index.html 并在当前 revision 执行 preview.validate。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口无法安全读取", + error, + )); + } + }; + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "preview-playtest 浏览器试玩回执不属于当前 revision", + "preview-playtest 浏览器试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -6589,13 +6628,13 @@ fn game_chat_main_completion_blocker_at_locked( ), )); } - let current_index_bytes = match read_autonomous_evidence_file_at( + let (current_index, current_index_bytes) = match read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, "game-chat 主 Agent 游戏入口", AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, ) { - Ok(Some((_, bytes))) => bytes, + Ok(Some(value)) => value, Ok(None) => { return Some(autonomous_completion_blocker( "game-chat code-prototype 尚未生成 game/index.html", @@ -6637,11 +6676,12 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + if !agent_runtime_static_smoke_passed_for_entry(&gate, revision.revision, ¤t_index.sha256) + { return Some(autonomous_completion_blocker( - "game-chat code-prototype 尚未通过当前 revision 的 game.static_smoke", + "game-chat code-prototype 尚未通过当前 game/index.html 的 game.static_smoke", format!( - "currentRevision={}, staticSmokeVerifiedRevision={}", + "path=game/index.html, currentRevision={}, staticSmokeVerifiedRevision={}", revision.revision, gate.static_smoke_verified_revision .map(|value| value.to_string()) @@ -6664,9 +6704,9 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "game-chat code-prototype 试玩回执不属于当前 revision", + "game-chat code-prototype 试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -7528,6 +7568,59 @@ pub(in crate::agent) fn javascript_is_syntactically_valid(content: &str, is_modu semantic.diagnostics.is_empty() } +pub(in crate::agent) fn validate_executable_inline_javascript_syntax( + html: &str, +) -> Result<(), String> { + let lower = html.to_ascii_lowercase(); + let mut cursor = 0usize; + let mut executable_index = 0usize; + while let Some(offset) = lower[cursor..].find('<') { + let tag_start = cursor + offset; + if lower[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag = &lower[tag_start..tag_after]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_after - 1) { + cursor = end; + continue; + } + if !html_tag_starts_element(tag, "script") { + cursor = tag_after; + continue; + } + let Some((close_start, close_end)) = + raw_text_html_element_close(&lower, tag_after, "script") + else { + return Err("游戏入口的 + +"#; + let error = validate_executable_inline_javascript_syntax(html) + .expect_err("a later script must still be syntax checked after a stray '<'"); + assert!(error.contains("不是有效 JavaScript"), "{error}"); + } + + #[test] + fn unterminated_less_than_alone_still_allows_a_valid_inline_script() { + let html = r#" + + +

a < b

+ + +"#; + validate_executable_inline_javascript_syntax(html) + .expect("stray '<' without '>' must not break valid syntax validation"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index f751ba781..fd05506d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -555,7 +555,16 @@ fn game_chat_manifest_seed_projection_starts_only_the_single_main_agent() { } fn cropped_spritesheet_game_html() -> &'static str { - "" + "

目标:移动角色收集全部目标,避开障碍并获得胜利;失败后可按 R 重新开始。

" +} + +fn with_static_smoke_contract(html: &str) -> String { + let contract = "

目标:移动角色收集全部目标并获得胜利;碰到危险即失败,按 R 重新开始。

"; + if let Some(body_end) = html.rfind("") { + format!("{}{}{}", &html[..body_end], contract, &html[body_end..]) + } else { + format!("{html}{contract}") + } } fn executable_tetris_game_html() -> String { @@ -1587,7 +1596,7 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro servers: Vec::new(), tools: Vec::new(), }; - let (_, _, provider_request, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, provider_request, _, _) = build_game_creator_agent_background_tool_plan_request( &root, &recovered.agent_id, &recovered.session_id, @@ -1623,15 +1632,17 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro .expect("continued root must pass the scheduler contract gate"); assert_eq!(scheduled.len(), 1); assert_eq!(scheduled[0].state.agent_id, "code-prototype"); - // Scheduling starts the child worker asynchronously. Wait for its - // durable terminal projection and lane release before mutating the same - // project; a lane release alone can precede a late manifest projection. + // Scheduling starts the child worker asynchronously. The deliberately + // empty Provider config now fails closed after the asset audit yields to + // Provider planning. Wait for that durable terminal projection and lane + // release before mutating the same project; a lane release alone can + // precede a late manifest projection. let _ = crate::tests::wait_for_agent_runtime_manifest_projection_async( &root, "code-prototype", &autonomous_manifest_ready_task_run_id(&continuation.run_id, "code-prototype"), "failed", - "budget-exhausted", + "failed", GameCreationAppTaskStatus::Failed, ) .await; @@ -5911,11 +5922,10 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { let readiness_child = queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); let readiness_state = agent_runtime_state_from_task_record(&readiness_child); - advance_game_index_revision( - &root, - &parent_state, - "静态检查通过", + let readiness_html = with_static_smoke_contract( + "静态检查通过", ); + advance_game_index_revision(&root, &parent_state, &readiness_html); mark_verification_passed(&root, &readiness_state, "game.static_smoke"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); @@ -6285,6 +6295,52 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { assert!(error.contains("场景指纹"), "unexpected error: {error}"); } +#[test] +fn autonomous_playtest_receipt_requires_passed_desktop_and_mobile_viewports() { + let (_temporary, root, state, contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-required-viewports-run"); + let revision = advance_game_index_revision( + &root, + &state, + "双视口试玩", + ); + let result = + browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证 desktop/mobile 试玩合同".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + + let mut duplicate_desktop = result.clone(); + duplicate_desktop.viewport_results[1].viewport = BrowserValidationViewport::Desktop; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &state, + &action_id, + &action_fingerprint, + revision, + &duplicate_desktop, + ) + .expect_err("duplicate desktop must not satisfy the mobile viewport gate"); + + let mut failed_mobile = result; + failed_mobile.viewport_results[1].passed = false; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &state, + &action_id, + &action_fingerprint, + revision, + &failed_mobile, + ) + .expect_err("failed mobile must not satisfy the dual viewport gate"); +} + #[test] fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { let (_temporary, root, state, contract) = autonomous_fixture( @@ -6511,6 +6567,107 @@ fn game_chat_parent_completion_stops_after_main_agent_smoke_and_dual_viewport_pl assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); } +#[test] +fn game_chat_completion_rejects_same_revision_entry_rewrite_after_static_smoke() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-entry-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + + let rewritten = format!( + "{}\n", + cropped_spritesheet_game_html() + ); + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), rewritten) + .expect("rewrite game entry without advancing project revision"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &main_state) + .expect("a static-smoke credential for different entry bytes must not complete"); + assert_eq!(blocker.tool, "runtime.autonomous_completion"); + assert!(blocker.summary.contains("game.static_smoke")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html"))); +} + +#[test] +fn game_chat_prepared_finalization_rejects_same_revision_entry_rewrite() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-prepared-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + main_state.status = "running".to_string(); + main_state.phase = "finalizing".to_string(); + main_state.current_action = "测试恢复 prepared finalization".to_string(); + append_game_creator_agent_runtime_task(&root, &main_state) + .expect("append finalizing game-chat main task"); + write_game_creator_agent_runtime_state(&root, &main_state) + .expect("persist finalizing game-chat main state"); + + let response = "不应复用旧静态检查凭证的完成回复"; + let journal = build_game_creator_agent_runtime_finalization_journal( + &root, + &main_state, + response, + revision, + ) + .expect("build prepared game-chat finalization"); + write_game_creator_agent_runtime_finalization_journal(&root, &journal) + .expect("write prepared game-chat finalization"); + append_game_creator_agent_runtime_finalization_lifecycle_stage( + &root, + &journal, + "prepared", + journal.prepared_at, + ) + .expect("write prepared game-chat lifecycle"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + format!( + "{}\n", + cropped_spritesheet_game_html() + ), + ) + .expect("rewrite game entry after prepared finalization"); + + assert_eq!( + resume_game_creator_agent_finalization_for_test_at(&root, &main_state.agent_id) + .expect("resume game-chat prepared finalization"), + "not-found" + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&main_state.agent_id), + Some(&main_state.session_id), + ) + .expect("read game-chat main conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); +} + #[test] fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { let (_temporary, root, state, _contract) = autonomous_fixture_with_source( @@ -6773,11 +6930,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { .expect("mark code prototype running"); let mut code_state = start_game_chat_main_agent(&root, &parent_state); persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) @@ -6793,7 +6949,8 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { "", "", ] { - let revision = advance_game_index_revision(&root, &code_state, html); + let html = with_static_smoke_contract(html); + let revision = advance_game_index_revision(&root, &code_state, &html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); assert!( @@ -6802,11 +6959,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { ); } - let overwritten_revision = advance_game_index_revision( - &root, - &code_state, + let overwritten_html = with_static_smoke_contract( "", ); + let overwritten_revision = advance_game_index_revision(&root, &code_state, &overwritten_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt( &root, @@ -6915,8 +7071,10 @@ fn canvas_visual_gate_rejects_dimensions_from_a_shadow_canvas_object() { #[test] fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "cli-art-gate-user", + "cli-art-gate-key", + "https://dev.genarrative.world", ); let (_temporary, root, parent_state, _contract) = autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); @@ -6925,11 +7083,10 @@ fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_co let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let code_state = agent_runtime_state_from_task_record(&code_record); - advance_game_index_revision( - &root, - &code_state, + let art_spec_only_html = with_static_smoke_contract( "", ); + advance_game_index_revision(&root, &code_state, &art_spec_only_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("CLI must still require the art spritesheet"); @@ -6993,8 +7150,10 @@ fn cli_code_prototype_accepts_linked_inline_and_external_modules_for_canvas_atla #[test] fn cli_code_prototype_rejects_unlinked_unreachable_modules_and_html_path_decoys() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"cli-module-decoy-key"}}"#.to_string(), + let _platform_session = crate::platform_session::install_test_platform_session( + "cli-module-decoy-user", + "cli-module-decoy-key", + "https://dev.genarrative.world", ); let (_temporary, root, parent_state, _contract) = autonomous_fixture("创建完整小游戏", "cli-module-decoy-parent"); @@ -7036,11 +7195,10 @@ fn game_chat_code_prototype_fails_closed_without_generated_spritesheet() { persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); fs::remove_file(root.join("assets/art-spritesheet.png")) .expect("remove required art-spritesheet file"); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); @@ -7268,6 +7426,13 @@ fn active_game_chat_child_keeps_dag_in_progress_after_completed_manifest_drift() ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Failed) .expect("fail the only game-chat manifest task"); + assert_eq!( + autonomous_manifest_dag_state_at(&root).expect("read failed DAG state with active child"), + AutonomousManifestDagState::Failed { + failed_task_ids: vec!["code-prototype".to_string()], + has_active_children: true, + } + ); assert!( !autonomous_manifest_dag_in_progress_at(&root).expect("read failed DAG with active child"), "a manifest failure must still fail closed" @@ -7984,6 +8149,7 @@ fn autonomous_owner_artifact_runtime_validation_unblocks_real_new_project_withou ..AgentRuntimeToolPlan::default() }, false, + false, ) .expect("owner response must reach Runtime validation after a long read tail"); mark_verification_passed(&root, &state, "game.static_smoke"); @@ -9224,11 +9390,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .as_deref() .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); - let revision = advance_game_index_revision( - &root, - &state, - "可玩塔防", - ); + let valid_game = cropped_spritesheet_game_html().to_string(); + let revision = advance_game_index_revision(&root, &state, &valid_game); mark_verification_passed(&root, &state, "project.verify"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("project.verify cannot replace static smoke"); @@ -9309,11 +9472,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); - advance_game_index_revision( - &root, - &state, - "试玩后又修改", - ); + let changed_game = format!("{valid_game}\n"); + advance_game_index_revision(&root, &state, &changed_game); mark_verification_passed(&root, &state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("stale playtest must block completion"); @@ -9326,11 +9486,10 @@ fn autonomous_prepared_finalization_without_playtest_is_discarded_before_assista "做一个植物大战僵尸式塔防游戏", "autonomous-prepared-finalization-run", ); - let revision = advance_game_index_revision( - &root, - &state, - "尚未试玩的塔防", + let unplayed_html = with_static_smoke_contract( + "尚未试玩的塔防", ); + let revision = advance_game_index_revision(&root, &state, &unplayed_html); mark_verification_passed(&root, &state, "game.static_smoke"); state.status = "running".to_string(); state.phase = "finalizing".to_string(); @@ -9390,6 +9549,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { last_verification_tool: Some("game.static_smoke".to_string()), last_verification_status: Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()), static_smoke_verified_revision: Some(2), + static_smoke_verified_game_index_sha256: Some("a".repeat(64)), failed_playtest_revision: None, updated_at: 0, }; @@ -9426,6 +9586,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { &superseded_failure, &response_plan, true, + false, true, ) .is_ok()); @@ -9448,6 +9609,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { &response_plan, true, false, + false, ) .expect_err("latest failed preview must still require delegated repair"); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index 8d0738c71..e7084d0f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -300,6 +300,11 @@ pub(crate) struct AgentRuntimeVerificationGate { /// proof that the exact same revision passed `game.static_smoke`. #[serde(default)] pub(crate) static_smoke_verified_revision: Option, + /// Bind the durable static-smoke credential to the exact entry bytes. + /// A project revision alone is insufficient because files can be changed + /// outside the Runtime mutation path without advancing that revision. + #[serde(default)] + pub(crate) static_smoke_verified_game_index_sha256: Option, #[serde(default)] pub(crate) failed_playtest_revision: Option, pub(crate) updated_at: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index c1ac898db..9fed91a54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -301,6 +301,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint( platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", + platform_llm::LlmResponseReasoningEffort::Max => "max", }); let text_verbosity = request.response_text_verbosity.map(|value| match value { platform_llm::LlmResponseTextVerbosity::Low => "low", @@ -398,6 +399,7 @@ fn plan_provider_reasoning_name(effort: platform_llm::LlmResponseReasoningEffort platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", + platform_llm::LlmResponseReasoningEffort::Max => "max", } } @@ -2198,6 +2200,25 @@ mod tests { assert!(error.contains("wire bytes 非法")); } + #[test] + fn provider_request_fingerprint_keeps_max_distinct_from_high() { + let base = LlmRunRequest::single_turn("系统", "任务"); + let high = game_creator_agent_runtime_llm_request_fingerprint( + &base + .clone() + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::High), + ) + .expect("high request fingerprint"); + let max_request = + base.with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); + let max = game_creator_agent_runtime_llm_request_fingerprint(&max_request) + .expect("max request fingerprint"); + let repeated_max = game_creator_agent_runtime_llm_request_fingerprint(&max_request) + .expect("repeated max request fingerprint"); + assert_ne!(high, max); + assert_eq!(max, repeated_max); + } + #[test] fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() { let error = platform_llm::LlmError::Transport(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index b31ae6420..061510810 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -49,6 +49,7 @@ pub(in crate::agent) fn default_agent_runtime_verification_gate( last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }) @@ -125,6 +126,20 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( "Agent Runtime verification gate 的 failedPlaytestRevision 必须大于 0".to_string(), ); } + if gate + .static_smoke_verified_game_index_sha256 + .as_deref() + .is_some_and(|sha256| !is_lowercase_sha256(sha256)) + { + return Err("Agent Runtime verification gate 的 static smoke 入口摘要无效".to_string()); + } + if gate.static_smoke_verified_revision.is_none() + && gate.static_smoke_verified_game_index_sha256.is_some() + { + return Err( + "Agent Runtime verification gate 的 static smoke 入口摘要缺少 revision".to_string(), + ); + } if gate .failed_playtest_revision .zip(gate.mutation_revision) @@ -196,11 +211,35 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( Ok(()) } -pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_revision( +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_entry( gate: &AgentRuntimeVerificationGate, revision: u64, + game_index_sha256: &str, ) -> bool { - revision > 0 && gate.static_smoke_verified_revision == Some(revision) + revision > 0 + && gate.static_smoke_verified_revision == Some(revision) + && gate.static_smoke_verified_game_index_sha256.as_deref() == Some(game_index_sha256) +} + +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_current_entry_at( + root: &Path, + gate: &AgentRuntimeVerificationGate, + revision: u64, +) -> Result { + let Some((game_index, _)) = read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + )? + else { + return Ok(false); + }; + Ok(agent_runtime_static_smoke_passed_for_entry( + gate, + revision, + &game_index.sha256, + )) } pub(crate) fn read_game_creator_agent_runtime_project_revision( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 7954fea9d..c37a10537 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -38,7 +38,10 @@ pub(in crate::agent) use task_ops::*; pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; #[cfg(test)] pub(crate) use delivery::{ - build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at, + build_static_delegate_result_for_child_at, + convert_game_chat_child_user_input_to_safe_default_repair, + trusted_game_chat_autonomous_root_parent_at, + wake_waiting_static_delegate_parent_run_for_test_at, }; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; @@ -55,7 +58,9 @@ pub(crate) use delegation::{ }; pub(crate) use delivery::{ agent_runtime_delegation_id, dispatch_isolated_agent_join_at, + game_chat_safe_default_repair_replacement, game_chat_safe_default_repair_task_instruction, game_creator_agent_runtime_terminal_status, publish_game_creator_agent_delegate_result, + reconcile_claimed_game_chat_safe_default_half_states_at, reconcile_game_creator_agent_delegate_receipts_at, }; #[allow(unused_imports)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 26ba39782..c3b421180 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -286,18 +286,18 @@ fn validate_game_chat_main_art_delegation_at( acceptance_criteria: &[String], expected_artifacts: &[String], repair_of_delegation_id: Option<&str>, -) -> Result<(), String> { +) -> Result { let Some(binding) = read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? else { - return Ok(()); + return Ok(false); }; let may_be_game_chat = binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE || (binding.source == "agent-ready-task-scheduler" && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); if !may_be_game_chat { - return Ok(()); + return Ok(false); } let root_binding = read_game_creator_agent_runtime_run_profile_binding( root, @@ -320,13 +320,32 @@ fn validate_game_chat_main_art_delegation_at( ); } if !is_game_chat_main { - return Ok(()); - } - if repair_of_delegation_id.is_some() { - return Err( - "game-chat 主 Agent 美术委派不允许发起返工链;请认领当前回执后重新审计".to_string(), - ); + return Ok(false); } + let safe_default_repair = if let Some(original_delegation_id) = repair_of_delegation_id { + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少 code-prototype 父任务".to_string())?; + let original = read_static_delegate_delivery_at(root, original_delegation_id)? + .ok_or_else(|| "game-chat 安全默认返工引用的原 delivery 不存在".to_string())?; + if original.repair_of_delegation_id.is_some() + || original.target_agent_id != target_agent_id + || original.acceptance_criteria != acceptance_criteria + || original.expected_artifacts != expected_artifacts + || !trusted_game_chat_safe_default_repair_delivery_at(root, &parent_task, &original)? + { + return Err( + "game-chat code-prototype 只能对同一安全默认 delivery 发起唯一、同合同的一层返工" + .to_string(), + ); + } + true + } else { + false + }; if !matches!(target_agent_id, "art-director" | "art-asset-plan") { return Err( "game-chat code-prototype 只能按审计结果委派 art-director 或 art-asset-plan" @@ -387,13 +406,14 @@ fn validate_game_chat_main_art_delegation_at( && delivery.parent_run_id == parent_run_id && delivery.parent_action_id == action_identity && delivery.target_agent_id == target_agent_id - && delivery.repair_of_delegation_id.is_none() + && delivery.repair_of_delegation_id.as_deref() == repair_of_delegation_id && delivery.status != StaticDelegateDeliveryStatus::Suppressed }); if static_delegate_target_agent_ids_at(root, parent_agent_id, parent_run_id)? .iter() .any(|existing_target| existing_target == target_agent_id) && !same_action_replay + && !safe_default_repair { return Err(format!( "game-chat 每个审计缺口最多委派一次:target={target_agent_id}" @@ -404,7 +424,7 @@ fn validate_game_chat_main_art_delegation_at( { return Err("game-chat code-prototype 同一时刻最多保留一个活跃美术委派".to_string()); } - Ok(()) + Ok(safe_default_repair) } pub(crate) fn observe_agent_runtime_agent_delegate( @@ -623,7 +643,68 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( detail: None, }; } - if repair_of_delegation_id.is_some() && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let mut safe_default_repair_instruction = None; + let game_chat_safe_default_repair = if let Some(original_delegation_id) = + repair_of_delegation_id.as_deref() + { + let parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id, + parent_run_id, + ) { + Ok(parent_task) => parent_task, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let original = match read_static_delegate_delivery_at(root, original_delegation_id) { + Ok(original) => original, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + match (parent_task.as_ref(), original.as_ref()) { + (Some(parent_task), Some(original)) => { + match trusted_game_chat_safe_default_repair_delivery_at(root, parent_task, original) + { + Ok(authorized) => { + if authorized { + safe_default_repair_instruction = original + .structured_result + .as_ref() + .and_then(game_chat_safe_default_repair_task_instruction); + } + authorized + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + } + } else { + false + }; + if repair_of_delegation_id.is_some() + && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && !game_chat_safe_default_repair + { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), @@ -668,7 +749,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( detail: None, }; } - if let Err(error) = validate_game_chat_main_art_delegation_at( + let game_chat_safe_default_repair = match validate_game_chat_main_art_delegation_at( root, agent_id, parent_run_id, @@ -678,13 +759,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( &expected_artifacts, repair_of_delegation_id.as_deref(), ) { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } + Ok(safe_default_repair) => safe_default_repair, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; if let Err(error) = ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, parent_run_id) { @@ -704,8 +788,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( &target_agent_id, delegation_action_identity, ); + let delegated_task_text = safe_default_repair_instruction + .map(|instruction| format!("{instruction}\n\n{task}")) + .unwrap_or(task); let delegated_task = match render_static_delegate_task_contract( - &task, + &delegated_task_text, agent_id, parent_run_id, &delegation_id, @@ -733,6 +820,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID }); + debug_assert!(!game_chat_safe_default_repair || main_art_delegation); let run_id = if run_id_input.trim().is_empty() { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || main_art_delegation { format!("delegated-{delegation_id}") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 92f338623..e6978ddfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1,5 +1,611 @@ use super::*; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION: &str = "game-chat-safe-default-repair.v1"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE: &str = "child-needs-user-input"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY: &str = "continue-with-safe-defaults"; +const GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE: &str = "preference-clarification"; +const GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE: &str = "sensitive-or-permission-request"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT: &str = "use-safe-default"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED: &str = "skip-denied"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY: &str = + "专业 Agent 请求了用户补充信息;自主构建必须采用安全默认值继续,并由 Supervisor 发起唯一返工。"; +const GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY: &str = + "自主构建已将专业 Agent 的补充信息请求转换为安全默认返工。"; + +fn game_chat_safe_default_request_allows_preference_default( + result: &StaticDelegateStructuredResult, +) -> bool { + let serialized = serde_json::to_string(&result.user_input_questions).unwrap_or_default(); + let lower = serialized.to_ascii_lowercase(); + if redact_secret_tokens(&serialized) != serialized + || redact_absolute_path_tokens(&serialized) != serialized + || [ + ".env", + "api key", + "api_key", + "apikey", + "token", + "secret", + "password", + "credential", + "authorization", + "cookie", + "bearer ", + "权限", + "授权", + "删除", + "发布", + "支付", + "外部副作用", + "permission", + "authorize", + "delete", + "publish", + "payment", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + return false; + } + [ + "偏好", + "视觉", + "配色", + "色彩", + "颜色", + "风格", + "主题", + "布局", + "难度", + "节奏", + "preference", + "visual", + "palette", + "color", + "style", + "theme", + "layout", + "difficulty", + "pace", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn game_chat_safe_default_repair_error(result: &StaticDelegateStructuredResult) -> String { + let use_preference_default = game_chat_safe_default_request_allows_preference_default(result); + serde_json::json!({ + "schemaVersion": GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION, + "code": GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE, + "strategy": GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY, + "reasonCode": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE + } else { + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE + }, + "defaultDecision": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + } else { + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + }, + "requestSha256": result.user_input_questions_sha256, + "summary": GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY, + }) + .to_string() +} + +fn game_chat_safe_default_repair_marker( + result: &StaticDelegateStructuredResult, +) -> Option { + if result.contract_status != StaticDelegateContractStatus::NeedsRepair + || !result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some() + { + return None; + } + let marker = serde_json::from_str::(result.error.as_deref()?).ok()?; + let reason = marker.get("reasonCode")?.as_str()?; + let decision = marker.get("defaultDecision")?.as_str()?; + let request_sha256 = marker.get("requestSha256")?.as_str()?; + (marker.get("schemaVersion")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION + && marker.get("code")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE + && marker.get("strategy")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY + && marker.get("summary")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY + && matches!( + (reason, decision), + ( + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE, + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + ) | ( + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE, + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + ) + ) + && request_sha256.len() == 64 + && request_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then_some(marker) +} + +pub(crate) fn game_chat_safe_default_repair_result_is_valid( + result: &StaticDelegateStructuredResult, +) -> bool { + game_chat_safe_default_repair_marker(result).is_some() +} + +pub(crate) fn game_chat_safe_default_repair_task_instruction( + result: &StaticDelegateStructuredResult, +) -> Option<&'static str> { + let marker = game_chat_safe_default_repair_marker(result)?; + match marker.get("defaultDecision")?.as_str()? { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT => Some( + "Runtime 安全默认决策:use-safe-default。仅采用无需用户补充、无需新增权限的普通偏好默认值继续;不得再次询问用户。", + ), + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED => Some( + "Runtime 安全默认决策:skip-denied。跳过被拒绝、敏感、越权或无法证明安全的输入与动作,仅在原合同和现有权限内继续;不得再次询问用户。", + ), + _ => None, + } +} + +pub(crate) fn game_chat_safe_default_repair_replacement( + result: &StaticDelegateStructuredResult, +) -> Option<(String, StaticDelegateStructuredResult)> { + let mut replacement = result.clone(); + if replacement.contract_status == StaticDelegateContractStatus::NeedsUserInput { + convert_game_chat_child_user_input_to_safe_default_repair(&mut replacement); + } else if !game_chat_safe_default_repair_result_is_valid(&replacement) { + return None; + } + Some(( + GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(), + replacement, + )) +} + +fn trusted_game_chat_autonomous_root_binding( + parent_task: &AgentRuntimeTaskRecord, + binding: &AgentRuntimeRunProfileBinding, +) -> bool { + parent_task.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && parent_task.run_id == binding.run_id + && parent_task.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && parent_task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.parent_agent_id.is_none() + && parent_task.parent_run_id.is_none() + && binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + && binding.parent_agent_id.is_none() + && binding.parent_run_id.is_none() + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.run_profile_binding_fingerprint == binding.binding_fingerprint +} + +fn trusted_game_chat_autonomous_parent_chain_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result, String> { + let Some(parent_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(None); + }; + if parent_task.agent_id != parent_binding.agent_id + || parent_task.run_id != parent_binding.run_id + || parent_task.source != parent_binding.source + || parent_task.run_profile != parent_binding.profile + || parent_task.parent_agent_id != parent_binding.parent_agent_id + || parent_task.parent_run_id != parent_binding.parent_run_id + || parent_task.run_profile_binding_fingerprint != parent_binding.binding_fingerprint + { + return Ok(None); + } + if trusted_game_chat_autonomous_root_binding(parent_task, &parent_binding) { + return Ok(Some(parent_binding)); + } + if parent_task.agent_id != "code-prototype" + || parent_task.source != "agent-ready-task-scheduler" + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.delegation_id.is_some() + || parent_binding.parent_agent_id.as_deref() + != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || parent_binding.parent_run_id.as_deref() != Some(parent_binding.root_run_id.as_str()) + || parent_binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_binding.parent_binding_fingerprint.is_none() + { + return Ok(None); + } + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_binding.root_agent_id, + &parent_binding.root_run_id, + )? + else { + return Ok(None); + }; + let Some(root_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + else { + return Ok(None); + }; + if !trusted_game_chat_autonomous_root_binding(&root_task, &root_binding) + || parent_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + { + return Ok(None); + } + Ok(Some(parent_binding)) +} + +fn trusted_game_chat_code_parent_task_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + if parent_agent_id != "code-prototype" { + return Ok(None); + } + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(None); + }; + if parent_task.agent_id != parent_agent_id + || parent_task.run_id != parent_run_id + || trusted_game_chat_autonomous_parent_chain_at(root, &parent_task)?.is_none() + { + return Ok(None); + } + Ok(Some(parent_task)) +} + +pub(in crate::agent) fn static_delegate_parent_can_manage_receipts_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(true); + } + let deliveries = + static_delegate_deliveries_for_parent_at(root, parent_agent_id, parent_run_id)?; + let active_deliveries = deliveries + .iter() + .filter(|delivery| delivery.status != StaticDelegateDeliveryStatus::Suppressed) + .collect::>(); + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + if parent_agent_id == "code-prototype" && !active_deliveries.is_empty() { + return Err( + "game-chat code-prototype 存在未收束静态委派,但无法证明当前父 Run 身份链可信" + .to_string(), + ); + } + return Ok(false); + }; + for delivery in active_deliveries { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, delivery, None)? { + return Err( + "game-chat code-prototype 父 Run 的静态委派 delivery 身份链不可信".to_string(), + ); + } + } + Ok(true) +} + +pub(in crate::agent) fn static_delegate_parent_can_manage_delivery_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id { + return Ok(false); + } + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(true); + } + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(false); + }; + trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, delivery, None) +} + +pub(in crate::agent) fn game_chat_code_parent_terminal_delivery_error_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(None); + }; + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, &delivery, None)? { + return Ok(Some( + "game-chat 专业美术回执不属于当前可信 code-prototype 父 Run,拒绝继续".to_string(), + )); + } + let Some(result) = delivery.structured_result.as_ref() else { + return Ok(Some(format!( + "game-chat 专业美术子任务 {} 缺少结构化终态合同,拒绝继续", + delivery.target_agent_id + ))); + }; + match result.contract_status { + StaticDelegateContractStatus::EvidenceReady => {} + StaticDelegateContractStatus::NeedsRepair + if delivery.repair_of_delegation_id.is_none() + && trusted_game_chat_safe_default_repair_delivery_at( + root, + &parent_task, + &delivery, + )? => {} + StaticDelegateContractStatus::NeedsRepair => { + return Ok(Some(format!( + "game-chat 专业美术子任务失败且不能自动返工:{};仅合法安全默认 marker 允许唯一一层同合同返工", + delivery.target_agent_id + ))); + } + StaticDelegateContractStatus::NeedsUserInput => { + return Ok(Some(format!( + "game-chat 专业美术子任务仍要求用户输入且未转换为安全默认返工:{}", + delivery.target_agent_id + ))); + } + StaticDelegateContractStatus::UserRevisionRequested => { + return Ok(Some(format!( + "game-chat 专业美术子任务等待用户修订后继续:{}", + delivery.target_agent_id + ))); + } + StaticDelegateContractStatus::Unknown(_) => { + return Ok(Some(format!( + "game-chat 专业美术子任务使用当前客户端无法识别的终态合同,拒绝继续:{}", + delivery.target_agent_id + ))); + } + } + } + Ok(None) +} + +pub(in crate::agent) fn game_chat_code_parent_safe_default_repair_delivery_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let Some(parent_task) = + trusted_game_chat_code_parent_task_at(root, parent_agent_id, parent_run_id)? + else { + return Ok(None); + }; + let barrier = static_delegate_completion_barrier_at(root, parent_agent_id, parent_run_id)?; + if barrier.repair_required_count == 0 { + return Ok(None); + } + let mut candidates = Vec::new(); + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if delivery.repair_of_delegation_id.is_none() + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsRepair + }) + && trusted_game_chat_safe_default_repair_delivery_at(root, &parent_task, &delivery)? + { + candidates.push(delivery); + } + } + if candidates.len() != barrier.repair_required_count { + return Err( + "game-chat code-prototype 存在待返工 delivery,但未全部通过安全默认 marker 与身份链校验" + .to_string(), + ); + } + if candidates.len() != 1 { + return Err( + "game-chat code-prototype 一次必须且只能推进一个安全默认返工 delivery".to_string(), + ); + } + Ok(candidates.pop()) +} + +pub(crate) fn trusted_game_chat_autonomous_root_parent_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + if parent_task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_task.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.parent_agent_id.is_some() + || parent_task.parent_run_id.is_some() + { + return Ok(false); + } + let Some(binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(false); + }; + Ok(trusted_game_chat_autonomous_root_binding( + parent_task, + &binding, + )) +} + +fn trusted_game_chat_autonomous_child_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, + supplied_child_task: Option<&AgentRuntimeTaskRecord>, +) -> Result { + let Some(parent_binding) = trusted_game_chat_autonomous_parent_chain_at(root, parent_task)? + else { + return Ok(false); + }; + if delivery.parent_agent_id != parent_task.agent_id + || delivery.parent_session_id != parent_task.session_id + || delivery.parent_run_id != parent_task.run_id + || agent_runtime_delegation_id( + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.target_agent_id, + &delivery.parent_action_id, + ) != delivery.delegation_id + || !matches!( + delivery.target_agent_id.as_str(), + "code-prototype" | "art-director" | "art-asset-plan" + ) + || (parent_task.agent_id == "code-prototype" + && !matches!( + delivery.target_agent_id.as_str(), + "art-director" | "art-asset-plan" + )) + { + return Err("game-chat 安全默认返工 delivery 与可信父责任链不一致".to_string()); + } + let child_task = match supplied_child_task { + Some(task) => task.clone(), + None => read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.target_agent_id, + &delivery.target_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child task".to_string())?, + }; + validate_static_delegate_delivery_for_child_result(delivery, parent_task, &child_task)?; + let child_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &child_task.agent_id, + &child_task.run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child binding".to_string())?; + if child_task.source != "agent-delegate" + || child_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_task.run_profile_binding_fingerprint != child_binding.binding_fingerprint + || child_binding.agent_id != delivery.target_agent_id + || child_binding.run_id != delivery.target_run_id + || child_binding.source != "agent-delegate" + || child_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_binding.root_agent_id != parent_binding.root_agent_id + || child_binding.root_run_id != parent_binding.root_run_id + || child_binding.parent_agent_id.as_deref() != Some(parent_task.agent_id.as_str()) + || child_binding.parent_run_id.as_deref() != Some(parent_task.run_id.as_str()) + || child_binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + { + return Err("game-chat 安全默认返工专业 child binding/link 身份不一致".to_string()); + } + Ok(true) +} + +pub(crate) fn convert_game_chat_child_user_input_to_safe_default_repair( + result: &mut StaticDelegateStructuredResult, +) -> bool { + if result.contract_status != StaticDelegateContractStatus::NeedsUserInput { + return false; + } + let error = game_chat_safe_default_repair_error(result); + result.contract_status = StaticDelegateContractStatus::NeedsRepair; + result.user_input_questions.clear(); + result.user_input_questions_sha256 = None; + result.error = Some(error); + true +} + +pub(in crate::agent) fn convert_claimed_game_chat_user_input_deliveries_to_repair_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result { + if trusted_game_chat_autonomous_parent_chain_at(root, parent_task)?.is_none() { + return Ok(0); + } + let mut converted = 0_usize; + for expected in deliveries.iter().filter(|delivery| { + delivery.parent_agent_id == parent_task.agent_id + && delivery.parent_session_id == parent_task.session_id + && delivery.parent_run_id == parent_task.run_id + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.clarification_request_id.is_none() + && delivery.clarification_answers_sha256.is_none() + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, parent_task, expected, None)? { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, expected)?; + converted = converted.saturating_add(1); + } + Ok(converted) +} + +pub(crate) fn reconcile_claimed_game_chat_safe_default_half_states_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(()); + }; + if trusted_game_chat_autonomous_parent_chain_at(root, &parent_task)?.is_none() { + return Ok(()); + } + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + || game_chat_safe_default_repair_result_is_valid(result) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, &delivery, None)? + { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, &delivery)?; + } + } + Ok(()) +} + +pub(crate) fn trusted_game_chat_safe_default_repair_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + Ok( + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.result_summary.as_deref() == Some(GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY) + && delivery + .structured_result + .as_ref() + .is_some_and(game_chat_safe_default_repair_result_is_valid) + && trusted_game_chat_autonomous_child_delivery_at(root, parent_task, delivery, None)?, + ) +} + pub(crate) fn agent_runtime_delegation_id( parent_agent_id: &str, parent_run_id: &str, @@ -353,7 +959,29 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ¤t_task.run_id, )?; let mut state = state; - if !ensure_static_delegate_user_input_wait_at_locked( + if trusted_game_chat_autonomous_root_parent_at(root, ¤t_task)? { + if !ensure_static_delegate_user_input_wait_at_locked( + root, + &mut state, + &deliveries, + &project_lock, + )? { + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "自主构建澄清已转换为安全默认返工", + "专业 Agent 回执已按安全默认策略进入 needs-repair,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + } + } else if !ensure_static_delegate_user_input_wait_at_locked( root, &mut state, &deliveries, @@ -928,8 +1556,8 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } let was_dispatched = existing_delivery.status == StaticDelegateDeliveryStatus::Dispatched; - let safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); - let structured_result = match build_static_delegate_result_for_child_at( + let mut safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); + let mut structured_result = match build_static_delegate_result_for_child_at( root, &existing_delivery, child_task, @@ -957,6 +1585,24 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } }; + match trusted_game_chat_autonomous_child_delivery_at( + root, + &parent_task, + &existing_delivery, + Some(child_task), + ) { + Ok(true) => { + if convert_game_chat_child_user_input_to_safe_default_repair(&mut structured_result) + { + safe_result_summary = GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(); + } + } + Ok(false) => {} + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + } let delivery = match mark_static_delegate_delivery_ready_with_result_at( root, &child_task.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index 2b23f9b8a..1a5c0002c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -7,16 +7,12 @@ pub(in crate::agent) fn observe_claimed_static_delegate_contract_at( delegation_id: &str, ) -> AgentRuntimeToolObservation { let result = (|| -> Result { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Err("只有 Project Supervisor 可以读取已认领委派合同".to_string()); - } let delivery = read_static_delegate_delivery_at(root, delegation_id)? - .ok_or_else(|| "指定委派合同不存在".to_string())?; - if delivery.parent_agent_id != agent_id - || delivery.parent_run_id != run_id + .ok_or_else(|| "指定委派合同不属于当前可信父 run 或尚未认领".to_string())?; + if !static_delegate_parent_can_manage_delivery_at(root, agent_id, run_id, &delivery)? || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent { - return Err("指定委派合同不属于当前 Supervisor 父 run 或尚未认领".to_string()); + return Err("指定委派合同不属于当前可信父 run 或尚未认领".to_string()); } let detail = serde_json::to_string(&serde_json::json!({ "claimedDelegateContract": { @@ -124,17 +120,15 @@ pub(crate) fn observe_agent_runtime_run_status( detail = format!("collaborationPolicy: {policy_status}\n\n{detail}"); } let claim_action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); - let static_delegate_output_may_be_present = - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - static_delegate_run_status_may_include_receipts_at( - root, - agent_id, - run_id, - claim_action_id, - )? - } else { - false - }; + let can_manage_static_delegate_receipts = + static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id)?; + let static_delegate_output_may_be_present = can_manage_static_delegate_receipts + && static_delegate_run_status_may_include_receipts_at( + root, + agent_id, + run_id, + claim_action_id, + )?; let isolated_join_payload_limit = if static_delegate_output_may_be_present { AGENT_RUNTIME_READY_ISOLATED_JOIN_MIXED_PAYLOAD_MAX_CHARS } else { @@ -169,7 +163,7 @@ pub(crate) fn observe_agent_runtime_run_status( detail = format!("claimedIsolatedJoins: {payload}\n\n{detail}"); } - let ready_delegate_receipts = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let ready_delegate_receipts = if can_manage_static_delegate_receipts { let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; if barrier.ready_unclaimed_count > 0 && claim_action_id.is_none() { return Err("agent.run_status 认领专业 Agent 回执必须绑定 actionId".to_string()); @@ -261,7 +255,7 @@ pub(crate) fn observe_agent_runtime_run_status( })), }; - let claimed_delegate_deliveries = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let claimed_delegate_deliveries = if can_manage_static_delegate_receipts { claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { Vec::new() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 65569b2b1..57b49828f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1435,7 +1435,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "为 game-chat 提交结构化条件任务图路由;Supervisor 自行概括并持久化用户 intentSummary,Runtime 采用审计优先策略,code-prototype 审计后再提交复用或真实缺口生成路由。" } "agent.action_history" => "查询当前 Agent 的持久终态动作历史。", - "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;Project Supervisor 可按 delegationId 取回已认领的权威返工合同。", + "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;当前可信父 Run 可按 delegationId 取回自己已认领的权威返工合同。", _ => "执行一个受 Runtime 白名单和项目策略保护的工具动作。", } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 1206f9714..a7097a6ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,4 +1,390 @@ use super::*; +use sha2::{Digest as _, Sha256}; +use std::future::Future; + +const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; +const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024; +const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = + "private-external-editor-credential-storage-preparation-failed"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = + "private-external-editor-credential-persistence-failed"; + +/// A task-scoped External Editor credential used only by the direct Codex +/// runtime. It never changes the GUI account session and deliberately keeps +/// the key private: callers may use it to make authenticated requests but may +/// not format or serialize this value for diagnostics. +#[derive(Clone)] +pub(crate) struct ExternalEditorApiCredentials { + api_base_url: String, + api_key: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PrivateExternalEditorApiKeyFile { + api_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + base_url: Option, +} + +tokio::task_local! { + static EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE: ExternalEditorApiCredentials; +} + +fn active_external_editor_api_credentials() -> Option { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .try_with(Clone::clone) + .ok() +} + +pub(crate) async fn with_external_editor_api_credentials( + credentials: ExternalEditorApiCredentials, + operation: impl Future, +) -> T { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .scope(credentials, operation) + .await +} + +#[cfg(test)] +pub(crate) fn external_editor_api_credentials_for_test( + api_base_url: String, + api_key: String, +) -> ExternalEditorApiCredentials { + ExternalEditorApiCredentials { + api_base_url, + api_key, + } +} + +fn private_external_editor_api_key_directory() -> Result { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?; + Ok(home.join(".config").join("genarrative")) +} + +fn private_external_editor_api_key_path_for_base_url( + api_base_url: &str, +) -> Result { + let api_base_url = normalize_external_editor_api_base_url(api_base_url)?; + let fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes())); + Ok(private_external_editor_api_key_directory()?.join(format!( + "{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX}{}.json", + &fingerprint[..16] + ))) +} + +pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = + url::Url::parse(value).map_err(|_| "陶泥儿 External Editor 地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("陶泥儿 External Editor 地址必须是纯 HTTP(S) origin".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "陶泥儿 External Editor 地址缺少 host".to_string())?; + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if parsed.scheme() == "http" && !loopback { + return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string()); + } + Ok(value.to_string()) +} + +fn normalize_external_editor_api_key(value: &str) -> Result { + let value = value.trim(); + if !value.starts_with("tnr_sk_") + || value.len() > 1024 + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err("本机陶泥儿开发者 API Key 无效,请重新登录客户端后重试".to_string()); + } + Ok(value.to_string()) +} + +fn private_external_editor_api_credentials_from_file_at( + path: &Path, +) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}")); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string()); + } + if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES { + return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, false)?; + let content = fs::read_to_string(path) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; + let parsed = serde_json::from_str::(&content) + .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let api_key = normalize_external_editor_api_key(&parsed.api_key)?; + let api_base_url = normalize_external_editor_api_base_url( + parsed + .base_url + .as_deref() + .unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL), + )?; + Ok(Some(ExternalEditorApiCredentials { + api_base_url, + api_key, + })) +} + +fn ensure_plain_private_external_editor_directory( + path: &Path, + label: &str, +) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(false) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = match fs::create_dir(path) { + Ok(()) => true, + Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => { + false + } + Err(create_error) => { + return Err(format!( + "创建本机陶泥儿开发者凭据{label}失败:{create_error}" + )); + } + }; + let metadata = fs::symlink_metadata(path).map_err(|metadata_error| { + format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(created) + } + Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")), + } +} + +/// Prepares the exact private directory before a one-time remote developer key +/// is requested. Existing directories are verified, not adopted; only the +/// directory created by this invocation may have its Windows owner initialized. +fn prepare_private_external_editor_api_credentials_parent_dir_at( + path: &Path, +) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少父目录".to_string())?; + let container = parent + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?; + ensure_plain_private_external_editor_directory(container, "上级目录")?; + let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?; + #[cfg(windows)] + if parent_created { + initialize_windows_game_creator_directory_owner_for_current_user(parent)?; + } else { + secure_windows_game_creator_path_for_current_user(parent, true, false)?; + } + #[cfg(unix)] + if parent_created { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?; + } + Ok(()) +} + +pub(crate) fn private_external_editor_credentials_storage_preparation_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE) +} + +pub(crate) fn private_external_editor_credentials_persistence_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE) +} + +fn write_private_external_editor_api_credentials_at( + path: &Path, + credentials: &ExternalEditorApiCredentials, +) -> Result<(), String> { + prepare_private_external_editor_api_credentials_parent_dir_at(path)?; + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少父目录".to_string())?; + let parent_metadata = fs::symlink_metadata(parent) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + } + if path.exists() { + return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + } + let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile { + api_key: credentials.api_key.clone(), + base_url: Some(credentials.api_base_url.clone()), + }) + .map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?; + let file_name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少文件名".to_string())?; + let temporary = parent.join(format!( + ".{file_name}.tmp.{}.{}", + std::process::id(), + unix_millis(), + )); + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?; + let write_result = file + .write_all(format!("{body}\n").as_bytes()) + .and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}")); + } + #[cfg(windows)] + initialize_windows_game_creator_file_owner_for_current_user(&temporary)?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + let _ = fs::remove_file(&temporary); + } + Err(error) => { + let _ = fs::remove_file(&temporary); + return Err(match error.kind() { + std::io::ErrorKind::AlreadyExists => { + "本机陶泥儿开发者 Key 已被另一个进程写入,请直接重试".to_string() + } + _ => format!("安全保存本机陶泥儿开发者 Key 失败:{error}"), + }); + } + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, true)?; + Ok(()) +} + +async fn create_private_external_editor_api_credentials_from_platform_session( +) -> Result { + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + })?; + let api_base_url = normalize_external_editor_api_base_url(&session.api_base_url)?; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?; + let response = client + .post(format!("{api_base_url}/api/profile/api-keys")) + .bearer_auth(&session.access_token) + .json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })) + .send() + .await + .map_err(|error| { + format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}") + })?; + let status = response.status(); + if !status.is_success() { + return Err(match status { + reqwest::StatusCode::UNAUTHORIZED => { + "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() + } + reqwest::StatusCode::FORBIDDEN => { + "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() + } + _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), + }); + } + let payload = response + .json::() + .await + .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + let api_key = payload + .get("apiKey") + .or_else(|| payload.pointer("/data/apiKey")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "创建本机陶泥儿开发者 Key 响应缺少一次性 Key".to_string())?; + Ok(ExternalEditorApiCredentials { + api_base_url, + api_key: normalize_external_editor_api_key(api_key)?, + }) +} + +/// Reads the user-private External Editor key used by the direct Runtime. If +/// it does not exist, an already authenticated GUI account may create exactly +/// one key and write it to private local storage. The GUI login token is never +/// copied into the file or passed to the later isolated Runtime. +pub(crate) async fn ensure_private_external_editor_api_credentials( +) -> Result { + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + })?; + let path = private_external_editor_api_key_path_for_base_url(&session.api_base_url)?; + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + ) + })?; + // Another local client may have completed the atomic write while this call + // prepared the directory. Re-read before creating a distinct remote key. + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + let credentials = + create_private_external_editor_api_credentials_from_platform_session().await?; + if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + return Err(format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + )); + } + Ok(credentials) +} + +pub(crate) fn platform_editor_uses_account_routes() -> bool { + active_external_editor_api_credentials().is_none() + && editor_api_mode() == EditorApiMode::PlatformAccount +} + +pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { + active_external_editor_api_credentials().is_some() +} pub(crate) fn upload_local_asset_at( root: &Path, @@ -247,9 +633,12 @@ pub(crate) async fn sync_canvas_project_assets_at( let api_key = resolve_canvas_sync_api_key(api_key)?; let client = reqwest::Client::new(); let project_url = format!( - "{}/api/external/v1/editor/projects/{}", + "{}{}", api_base_url, - percent_encode_query_component(canvas_project_id) + resolve_platform_editor_api_route(&format!( + "/api/external/v1/editor/projects/{}", + percent_encode_query_component(canvas_project_id) + )) ); let project_response = client .get(project_url) @@ -615,7 +1004,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( api_key, resource, max_bytes, - "/api/external/v1/assets/read-url", + &resolve_platform_editor_api_route("/api/external/v1/assets/read-url"), ) .await } @@ -776,6 +1165,14 @@ pub(crate) async fn resolve_external_asset_signed_url( pub(crate) fn resolve_canvas_sync_api_base_url( api_base_url: Option, ) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_base_url); + } + if editor_api_mode() == EditorApiMode::PlatformAccount { + return current_platform_session() + .map(|session| session.api_base_url) + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string()); + } let config = load_game_creator_app_config()?; let value = trim_optional_string(api_base_url) .or_else(|| trim_config_string(&config.editor_api.base_url)) @@ -789,17 +1186,121 @@ pub(crate) fn resolve_canvas_sync_api_base_url( } pub(crate) fn resolve_canvas_sync_api_key(api_key: Option) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_key); + } + if editor_api_mode() == EditorApiMode::PlatformAccount { + return current_platform_session() + .map(|session| session.access_token) + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string()); + } let config = load_game_creator_app_config()?; trim_optional_string(api_key) .or_else(|| trim_config_string(&config.editor_api.api_key)) .ok_or_else(|| { format!( - "画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key", + "请在 {} 的 editorApi.apiKey 中设置开发者 API Key", game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) ) }) } +pub(crate) fn resolve_canvas_sync_api_credentials( + api_base_url: Option, + api_key: Option, +) -> Result<(String, String, Option), String> { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok((credentials.api_base_url, credentials.api_key, None)); + } + if editor_api_mode() == EditorApiMode::PlatformAccount { + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + return Ok(( + session.api_base_url.clone(), + session.access_token.clone(), + Some(session), + )); + } + Ok(( + resolve_canvas_sync_api_base_url(api_base_url)?, + resolve_canvas_sync_api_key(api_key)?, + None, + )) +} + +pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String { + resolve_platform_editor_api_route_for_mode( + external_route, + platform_editor_uses_account_routes(), + ) +} + +fn resolve_platform_editor_api_route_for_mode( + external_route: &str, + platform_session_available: bool, +) -> String { + if !platform_session_available { + return external_route.to_string(); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") { + return format!("/api/editor/{suffix}"); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") { + return format!("/api/assets/{suffix}"); + } + external_route.to_string() +} + +pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String { + resolve_platform_generation_status_route_for_mode( + operation_id, + platform_editor_uses_account_routes(), + ) +} + +pub(crate) fn editor_api_authentication_error() -> String { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "authentication-required: External Editor API Key 无效或权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { + "authentication-required: 陶泥儿登录已失效,请重新登录".to_string() + } + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") + } + } +} + +pub(crate) fn editor_api_authorization_error() -> String { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "permission-denied: External Editor API Key 权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { + "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() + } + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") + } + } +} + +fn resolve_platform_generation_status_route_for_mode( + operation_id: &str, + platform_session_available: bool, +) -> String { + let operation_id = percent_encode_query_component(operation_id); + if platform_session_available { + format!("/api/runtime/external-generation/jobs/{operation_id}") + } else { + format!("/api/external/v1/generations/{operation_id}") + } +} + +pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value { + let data = payload.get("data").unwrap_or(payload); + data.get("job") + .filter(|job| job.is_object()) + .unwrap_or(data) +} + pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option { value .get(field) @@ -1050,7 +1551,7 @@ pub(crate) fn register_local_asset_entry( ) -> Result { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + let manifest_path = root.join(".agent/manifest.json"); let kind = if kind.is_empty() { "asset" } else { kind }; let media_type = if media_type.is_empty() { "application/octet-stream" @@ -1060,31 +1561,32 @@ pub(crate) fn register_local_asset_entry( let mut source_for_record = source.clone(); source_for_record.prompt = None; - let (id, record_type) = if let Some(existing) = manifest - .assets - .iter_mut() - .find(|asset| asset.local_path == normalized_path) - { - existing.kind = kind.to_string(); - existing.media_type = media_type.to_string(); - existing.source = source; - (existing.id.clone(), "asset.update") - } else { - let id = format!( - "{id_prefix}-{}-{}", - unix_millis(), - manifest.assets.len() + 1 - ); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: id.clone(), - kind: kind.to_string(), - media_type: media_type.to_string(), - local_path: normalized_path.clone(), - source, - }); - (id, "asset.register") - }; - write_manifest(&manifest_path, &manifest)?; + let (id, record_type) = mutate_manifest_at(root, |manifest| { + if let Some(existing) = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == normalized_path) + { + existing.kind = kind.to_string(); + existing.media_type = media_type.to_string(); + existing.source = source; + Ok((existing.id.clone(), "asset.update")) + } else { + let id = format!( + "{id_prefix}-{}-{}", + unix_millis(), + manifest.assets.len() + 1 + ); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: id.clone(), + kind: kind.to_string(), + media_type: media_type.to_string(), + local_path: normalized_path.clone(), + source, + }); + Ok((id, "asset.register")) + } + })?; append_agent_db_record( root, serde_json::json!({ @@ -1110,6 +1612,161 @@ mod tests { use super::*; use std::io::{Read, Write}; + #[test] + fn platform_session_maps_external_shaped_calls_to_first_party_routes() { + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,), + "/api/editor/projects" + ); + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,), + "/api/assets/read-url" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation/a b", true), + "/api/runtime/external-generation/jobs/operation%2Fa%20b" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation/a b", false), + "/api/external/v1/generations/operation%2Fa%20b" + ); + let payload = serde_json::json!({ + "data": { + "job": { + "operationId": "operation-1", + "status": "completed", + "result": { "objectKey": "generated/result.png" }, + } + } + }); + assert_eq!( + platform_generation_status_data(&payload)["result"]["objectKey"], + "generated/result.png" + ); + } + + #[test] + fn advanced_external_mode_keeps_external_routes_without_platform_session() { + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,), + "/api/external/v1/editor/projects" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation-1", false), + "/api/external/v1/generations/operation-1" + ); + } + + #[test] + fn private_external_editor_credentials_require_a_trusted_origin_and_key_shape() { + assert_eq!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/") + .expect("trusted dev origin"), + "https://dev.genarrative.world" + ); + assert_eq!( + normalize_external_editor_api_base_url("http://127.0.0.1:8085/"), + Ok("http://127.0.0.1:8085".to_string()) + ); + assert!(normalize_external_editor_api_base_url("http://staging.example.com").is_err()); + assert_eq!( + normalize_external_editor_api_base_url("https://untrusted.example.test"), + Ok("https://untrusted.example.test".to_string()) + ); + assert!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err() + ); + assert_eq!( + normalize_external_editor_api_key(" tnr_sk_fixture_123 ").expect("fixture API key"), + "tnr_sk_fixture_123" + ); + assert!(normalize_external_editor_api_key("plain-token").is_err()); + assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err()); + } + + #[test] + fn private_external_editor_credentials_are_isolated_by_server_origin() { + let local = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8082") + .expect("local credential path"); + let previous = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085") + .expect("previous local credential path"); + let dev = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("dev credential path"); + + assert_ne!(local, previous); + assert_ne!(local, dev); + assert!(local + .file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| name.starts_with(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX))); + } + + #[test] + fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() { + assert!( + private_external_editor_credentials_storage_preparation_failed( + "private-external-editor-credential-storage-preparation-failed: safe public detail" + ) + ); + assert!(private_external_editor_credentials_persistence_failed( + "private-external-editor-credential-persistence-failed: safe public detail" + )); + assert!( + !private_external_editor_credentials_storage_preparation_failed("ordinary failure") + ); + assert!(!private_external_editor_credentials_persistence_failed( + "ordinary failure" + )); + } + + #[cfg(windows)] + #[test] + fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() { + let root = tempfile::tempdir().expect("temp dir"); + let path = root + .path() + .join("config") + .join("genarrative") + .join("external-editor-api-test.json"); + + prepare_private_external_editor_api_credentials_parent_dir_at(&path) + .expect("prepare private credential directory"); + let parent = path.parent().expect("credential directory"); + secure_windows_game_creator_path_for_current_user(parent, true, false) + .expect("new credential directory must be owned by TokenUser"); + } + + #[tokio::test] + async fn direct_runtime_private_credentials_force_external_v1_routes_without_session_copy() { + let credentials = ExternalEditorApiCredentials { + api_base_url: "https://dev.genarrative.world".to_string(), + api_key: "tnr_sk_fixture_123".to_string(), + }; + with_external_editor_api_credentials(credentials, async { + assert!(external_editor_api_credentials_override_is_active()); + assert!(!platform_editor_uses_account_routes()); + assert_eq!( + resolve_canvas_sync_api_base_url(None).expect("scoped API base URL"), + "https://dev.genarrative.world" + ); + assert_eq!( + resolve_canvas_sync_api_key(None).expect("scoped API key"), + "tnr_sk_fixture_123" + ); + assert_eq!( + resolve_platform_editor_api_route("/api/external/v1/editor/projects"), + "/api/external/v1/editor/projects" + ); + assert_eq!( + resolve_platform_generation_status_route("operation-1"), + "/api/external/v1/generations/operation-1" + ); + }) + .await; + assert!(!external_editor_api_credentials_override_is_active()); + } + fn read_asset_test_request(stream: &mut std::net::TcpStream) { stream .set_read_timeout(Some(Duration::from_secs(2))) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index aded90b99..7b605276b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -16,7 +16,9 @@ pub use model::{ BrowserValidationInput, BrowserValidationResult, BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, DiscoveredBrowserKind, }; -pub use process::validate_local_preview_in_browser; +pub use process::{ + validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, +}; pub(crate) use playtest::browser_playtest_scenario_fingerprint; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs index c9120792b..ee5048997 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs @@ -156,6 +156,7 @@ pub(super) struct PageSnapshot { pub(super) dom_character_count: usize, pub(super) expected_text_matches: Vec, pub(super) canvases: Vec, + pub(super) canvas_or_webgl_image_paths: Vec, pub(super) blocked_popup_count: u32, pub(super) blocked_download_count: u32, pub(super) blocked_permission_count: u32, @@ -612,6 +613,7 @@ pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result text.includes(value)), canvases, + canvasOrWebglImagePaths: Array.from(new Set(security.canvasOrWebglImagePaths || [])).slice(0, 32), blockedPopupCount: security.blockedPopupCount || 0, blockedDownloadCount: security.blockedDownloadCount || 0, blockedPermissionCount: security.blockedPermissionCount || 0, @@ -702,6 +704,47 @@ pub(super) const RESTRICTION_SCRIPT: &str = r#" })(); "#; +pub(super) const RENDERED_IMAGE_CAPTURE_SCRIPT: &str = r#" +(() => { + const state = window.__GENARRATIVE_PREVIEW_VALIDATION__; + if (!state) return; + state.canvasOrWebglImagePaths = []; + const recordCanvasOrWebglImage = (candidate) => { + const source = candidate && (candidate.currentSrc || candidate.src); + if (typeof source !== 'string' || source.length === 0) return; + try { + const url = new URL(source, location.href); + if (url.origin !== location.origin || !url.pathname) return; + if (!state.canvasOrWebglImagePaths.includes(url.pathname) && state.canvasOrWebglImagePaths.length < 32) { + state.canvasOrWebglImagePaths.push(url.pathname); + } + } catch (_) {} + }; + const wrapImageRenderMethod = (prototype, method) => { + const original = prototype && prototype[method]; + if (typeof original !== 'function') return; + try { + Object.defineProperty(prototype, method, { + value: function(...args) { + for (const candidate of args) recordCanvasOrWebglImage(candidate); + return original.apply(this, args); + }, + configurable: true, + writable: true + }); + } catch (_) {} + }; + wrapImageRenderMethod(globalThis.CanvasRenderingContext2D?.prototype, 'drawImage'); + for (const prototype of [ + globalThis.WebGLRenderingContext?.prototype, + globalThis.WebGL2RenderingContext?.prototype + ]) { + wrapImageRenderMethod(prototype, 'texImage2D'); + wrapImageRenderMethod(prototype, 'texSubImage2D'); + } +})(); +"#; + fn remote_object_text(object: &RemoteObject) -> String { if let Some(value) = &object.value { if let Some(value) = value.as_str() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs index e56a8a0ad..71cc23fa0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs @@ -14,7 +14,8 @@ use url::Url; use super::capture::{ build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks, - truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT, + truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, + RESTRICTION_SCRIPT, }; use super::evidence::write_atomic; use super::model::{ @@ -36,6 +37,7 @@ pub(super) async fn run_browser_validation( discovered: &DiscoveredBrowser, preview_url: &Url, input: &BrowserValidationInput, + advisory_interaction: bool, ) -> Result { browser .execute(SetDownloadBehaviorParams::new( @@ -51,7 +53,8 @@ pub(super) async fn run_browser_validation( let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); let mut playtest = None; for viewport in REQUIRED_VIEWPORTS { - let outcome = validate_viewport(browser, preview_url, input, viewport).await?; + let outcome = + validate_viewport(browser, preview_url, input, viewport, advisory_interaction).await?; if outcome.playtest.is_some() { playtest = outcome.playtest; } @@ -116,6 +119,7 @@ async fn validate_viewport( preview_url: &Url, input: &BrowserValidationInput, viewport: BrowserValidationViewport, + advisory_interaction: bool, ) -> Result { let (width, height, mobile) = viewport.dimensions(); let page = browser @@ -139,6 +143,14 @@ async fn validate_viewport( page.evaluate_on_new_document(RESTRICTION_SCRIPT) .await .map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?; + page.evaluate_on_new_document(RENDERED_IMAGE_CAPTURE_SCRIPT) + .await + .map_err(|error| { + format!( + "安装 {} 平台素材渲染观察失败:{error}", + viewport.file_stem() + ) + })?; let tasks = start_capture_tasks(&page, preview_url).await?; let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str())) @@ -151,6 +163,12 @@ async fn validate_viewport( } tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; + let advisory_interaction_diagnostic = if advisory_interaction { + Some(run_advisory_interaction_probe(&page).await) + } else { + None + }; + let playtest = if viewport == BrowserValidationViewport::Desktop { match input.playtest_scenario { Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), @@ -245,7 +263,15 @@ async fn validate_viewport( if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) { diagnostics.push(diagnostic.to_string()); } - + if !snapshot.canvas_or_webgl_image_paths.is_empty() { + diagnostics.push(format!( + "[advisory] rendered-local-images:{}", + snapshot.canvas_or_webgl_image_paths.join("|") + )); + } + if let Some(diagnostic) = advisory_interaction_diagnostic { + diagnostics.push(format!("[advisory] {diagnostic}")); + } Ok(BrowserViewportValidationOutcome { result: BrowserViewportValidationResult { viewport, @@ -269,9 +295,61 @@ async fn validate_viewport( blocked_permission_count: snapshot.blocked_permission_count, blocked_service_worker_count: snapshot.blocked_service_worker_count, screenshot_path, - passed: diagnostics.is_empty(), + passed: diagnostics.is_empty() + || diagnostics + .iter() + .all(|diagnostic| diagnostic.starts_with("[advisory]")), diagnostics, }, playtest, }) } + +async fn run_advisory_interaction_probe(page: &chromiumoxide::Page) -> String { + const PROBE: &str = r#"function() { + const candidates = Array.from(document.querySelectorAll( + 'button:not([disabled]), a[href], input[type="button"]:not([disabled]), input[type="submit"]:not([disabled])' + )); + const item = candidates.find((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && + rect.width > 0 && rect.height > 0 && + element.getAttribute('aria-disabled') !== 'true'; + }); + if (!item) return JSON.stringify({ ok: false, reason: 'no-visible-enabled-control' }); + item.scrollIntoView({ block: 'center', inline: 'center' }); + return JSON.stringify({ ok: true, tag: item.tagName.toLowerCase() }); + }"#; + let mut elements = match page.find_elements( + "button:not([disabled]), a[href], input[type=button]:not([disabled]), input[type=submit]:not([disabled])", + ) + .await + { + Ok(elements) => elements, + Err(_) => return "真实交互探针无法查询可见控件".to_string(), + }; + for element in elements.drain(..).take(8) { + let evaluated = match element.call_js_fn(PROBE, false).await { + Ok(value) => value, + Err(_) => continue, + }; + let Some(value) = evaluated.result.value else { + continue; + }; + let Some(text) = value.as_str() else { + continue; + }; + let Ok(probe) = serde_json::from_str::(text) else { + continue; + }; + if probe.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + continue; + } + return match element.click().await { + Ok(_) => "真实交互探针已点击一个可见启用控件".to_string(), + Err(_) => "真实交互探针发现控件但点击未完成".to_string(), + }; + } + "真实交互探针未发现可见启用控件".to_string() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 0223b16c9..7366df54d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -34,6 +34,17 @@ pub(super) fn create_browser_process_temp_dir() -> Result { pub async fn validate_local_preview_in_browser( input: BrowserValidationInput, +) -> Result { + validate_local_preview_in_browser_with_interaction(input, false).await +} + +/// Runs the same restricted Chromium validation, optionally adding one bounded, +/// advisory click on a visible enabled control. The interaction is evidence for +/// the direct Codex reviewer; it is not a fixed gameplay contract and never +/// changes the deterministic browser safety checks. +pub async fn validate_local_preview_in_browser_with_interaction( + input: BrowserValidationInput, + advisory_interaction: bool, ) -> Result { let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; @@ -77,8 +88,14 @@ pub async fn validate_local_preview_in_browser( } }); - let validation = - run_browser_validation(&browser, &browser_executable, &preview_url, &input).await; + let validation = run_browser_validation( + &browser, + &browser_executable, + &preview_url, + &input, + advisory_interaction, + ) + .await; let close_result = browser .close() diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 086f410d2..ef4709782 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -12,7 +12,7 @@ use url::Url; use super::capture::{ canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params, - BrowserCanvasSnapshot, + BrowserCanvasSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, }; use super::discovery::system_browser_candidates; use super::evidence::browser_validation_result_for_report; @@ -70,6 +70,16 @@ fn canvas_snapshot( } } +#[test] +fn rendered_image_capture_script_observes_canvas_and_webgl_without_cross_origin_urls() { + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("CanvasRenderingContext2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("drawImage")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texSubImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.origin !== location.origin")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.pathname")); +} + fn generic_state(sequence: u64, phase: BrowserPlaytestPhase, level: u64) -> PlayableWebGameState { PlayableWebGameState { sequence, @@ -2362,20 +2372,41 @@ async fn real_chrome_validation_smoke() { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); let port = listener.local_addr().expect("preview address").port(); listener.set_nonblocking(true).expect("nonblocking preview"); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([255, 96, 32, 255]), + )) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("encode browser fixture PNG"); let (stop_tx, stop_rx) = mpsc::channel(); let server = thread::spawn(move || { - let html = br#"Browser Probe
Expected local preview
"#; + let html = br#"Browser Probe
Expected local preview
side only"#; while stop_rx.try_recv().is_err() { match listener.accept() { Ok((mut stream, _)) => { let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request); + let count = stream.read(&mut request).unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..count]); + let is_png = request.starts_with("GET /drawn.png ") + || request.starts_with("GET /side-only.png "); + let body = if is_png { + png.as_slice() + } else { + html.as_slice() + }; + let content_type = if is_png { + "image/png" + } else { + "text/html; charset=utf-8" + }; let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() ); let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(html); + let _ = stream.write_all(body); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); @@ -2389,7 +2420,7 @@ async fn real_chrome_validation_smoke() { url: format!("http://127.0.0.1:{port}/"), viewports: REQUIRED_VIEWPORTS.to_vec(), expected_text: vec!["Expected local preview".to_string()], - settle_ms: 100, + settle_ms: 300, fail_on_console_error: true, playtest_scenario: None, evidence_root: evidence.path().join("evidence"), @@ -2425,5 +2456,12 @@ async fn real_chrome_validation_smoke() { ); assert_eq!(viewport_result.canvases[1].non_empty, Some(false)); assert_eq!(viewport_result.canvases[2].non_empty, Some(true)); + let rendered_diagnostic = viewport_result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.starts_with("[advisory] rendered-local-images:")) + .expect("rendered image observation"); + assert!(rendered_diagnostic.contains("/drawn.png")); + assert!(!rendered_diagnostic.contains("/side-only.png")); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 076a4f0aa..d957fc284 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -13,6 +13,10 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, + DirectCodexChat { + project_path: PathBuf, + prompt: String, + }, AgentTask { project_path: PathBuf, agent_id: String, @@ -24,6 +28,7 @@ pub(crate) enum CliCommand { parent_agent_id: String, initialize: bool, run_profile: String, + supervisor_source: &'static str, }, AgentEnqueue { project_path: PathBuf, @@ -185,6 +190,7 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } + | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -686,8 +692,21 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } + if args.first().map(String::as_str) == Some("--direct-codex-chat") { + if args.len() < 3 { + return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); + } + let prompt = args[2..].join(" "); + if prompt.trim().is_empty() { + return Err("聊天内容不能为空".to_string()); + } + return Ok(Some(CliCommand::DirectCodexChat { + project_path: PathBuf::from(&args[1]), + prompt: prompt.trim().to_string(), + })); + } if args.first().map(String::as_str) == Some("--swarm-chat") { - const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--game-chat-smoke] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -711,9 +730,36 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } _ => return Err(USAGE.to_string()), }; + let game_chat_smoke = match rest + .iter() + .filter(|arg| arg.as_str() == "--game-chat-smoke") + .count() + { + 0 => false, + 1 => { + let index = rest + .iter() + .position(|arg| arg == "--game-chat-smoke") + .expect("counted game-chat smoke flag"); + rest.remove(index); + true + } + _ => return Err(USAGE.to_string()), + }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } + if game_chat_smoke + && (!autonomous_game_build + || rest.get(1).is_some_and(|parent| { + parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + })) + { + return Err( + "--game-chat-smoke 仅允许 project-supervisor 的 --autonomous-game-build 受限验收入口" + .to_string(), + ); + } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest @@ -726,6 +772,11 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } else { AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() }, + supervisor_source: if game_chat_smoke { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + }, })); } if args.first().map(String::as_str) == Some("--agent-task") { @@ -896,6 +947,36 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } + CliCommand::DirectCodexChat { + project_path, + prompt, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; + if !project_path.join(".agent/manifest.json").is_file() { + let project_name = project_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Codex 直连项目"); + init_local_game_project_at( + &project_path, + &format!("direct-codex-{}", unix_millis()), + project_name, + )?; + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; + let reply_result = runtime + .block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await }); + let shutdown_result = shutdown_game_creator_codex_app_servers(); + let reply = reply_result?; + shutdown_result?; + println!("direct-codex.chat.completed"); + println!("projectPath={}", project_path.display()); + println!("replyText={reply}"); + Ok(()) + } CliCommand::AgentTask { project_path, agent_id, @@ -984,11 +1065,17 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { parent_agent_id, initialize, run_profile, + supervisor_source, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; - run_game_creator_swarm_chat_at(&project_path, &parent_agent_id, &run_profile) + run_game_creator_swarm_chat_at( + &project_path, + &parent_agent_id, + &run_profile, + supervisor_source, + ) } CliCommand::AgentEnqueue { project_path, @@ -1869,6 +1956,7 @@ mod tests { parent_agent_id: "code-prototype".to_string(), initialize: true, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); assert!(command.requires_external_agent_runner()); @@ -1948,6 +2036,7 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } @@ -1970,10 +2059,57 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } + #[test] + fn swarm_chat_game_chat_smoke_flag_is_restricted_and_selects_trusted_source() { + let project_path = std::env::current_dir().expect("current directory"); + let command = parse_cli_command(&[ + "--swarm-chat".to_string(), + "--init".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + project_path.display().to_string(), + ]) + .expect("parse game-chat smoke") + .expect("game-chat smoke command"); + assert_eq!( + command, + CliCommand::SwarmChat { + project_path, + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + initialize: true, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + } + ); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + } + #[test] fn swarm_chat_rejects_missing_or_extra_arguments() { assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c816c4fa7..d63152965 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1347,6 +1347,30 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() } +#[tauri::command] +pub(crate) fn install_platform_account_session( + user_id: String, + access_token: String, + api_base_url: String, + generation: u64, +) -> Result<(), String> { + install_platform_session(&user_id, &access_token, &api_base_url, generation)?; + let _ = install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + ); + Ok(()) +} + +#[tauri::command] +pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { + clear_platform_session(generation); + let _ = clear_external_agent_runner_platform_session(generation); + Ok(()) +} + #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -1358,8 +1382,7 @@ pub(crate) fn write_game_creator_app_config( ) -> Result { let config = normalize_game_creator_app_config(config)?; let path = writable_game_creator_config_path()?; - let content = serde_json::to_string_pretty(&config) - .map_err(|error| format!("序列化客户端配置失败:{error}"))?; + let content = serialize_game_creator_app_config_for_renderer_write(&config)?; write_game_creator_config_atomically(&path, &format!("{content}\n"))?; game_creator_app_config_view(load_game_creator_app_config()?) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 84f28a379..ae1430676 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -150,8 +150,9 @@ pub(crate) fn parse_game_creator_llm_reasoning_effort( "low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)), "medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)), "high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)), + "max" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)), value => Err(format!( - "LLM reasoning_effort 无效:{value},请使用 default、low、medium 或 high" + "LLM reasoning_effort 无效:{value},请使用 default、low、medium、high 或 max" )), } } @@ -212,7 +213,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, @@ -834,6 +835,16 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +/// Initialize ownership only for a directory that was created by the current +/// operation. Existing directories must use the strict verifier instead, so a +/// foreign-owned path is never silently adopted. +#[cfg(windows)] +pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user( + path: &Path, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true) +} + #[cfg(windows)] pub(crate) fn windows_private_dacl_security_information( initialize_owner: bool, @@ -1328,14 +1339,31 @@ pub(crate) fn load_game_creator_app_config() -> Result Result { + if editor_api_mode() == EditorApiMode::PlatformAccount { + config.editor_api = GameCreatorEditorApiConfig::default(); + } Ok(GameCreatorAppConfigView { path: writable_game_creator_config_path()?.display().to_string(), config, }) } +pub(crate) fn serialize_game_creator_app_config_for_renderer_write( + config: &GameCreatorAppConfig, +) -> Result { + let mut value = + serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?; + if editor_api_mode() == EditorApiMode::PlatformAccount { + value + .as_object_mut() + .ok_or_else(|| "客户端配置必须是 JSON object".to_string())? + .remove("editorApi"); + } + serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}")) +} + pub(crate) fn writable_game_creator_config_path() -> Result { if let Some(config_dir) = game_creator_runtime_config_dir() { return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME)); diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 3f5d671c0..7f9eec417 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -515,6 +515,7 @@ pub(crate) fn static_delegate_completion_barrier_at( ) -> Result { validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + reconcile_claimed_game_chat_safe_default_half_states_at(root, parent_agent_id, parent_run_id)?; let claims = list_static_delegate_claims_at(root)? .into_iter() .filter(|claim| { @@ -1800,7 +1801,7 @@ pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at( Ok(()) } -pub(crate) fn claimed_static_delegate_deliveries_at( +pub(crate) fn static_delegate_deliveries_for_parent_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, @@ -1810,15 +1811,26 @@ pub(crate) fn claimed_static_delegate_deliveries_at( let mut deliveries = list_static_delegate_deliveries_at(root)? .into_iter() .filter(|delivery| { - delivery.parent_agent_id == parent_agent_id - && delivery.parent_run_id == parent_run_id - && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + delivery.parent_agent_id == parent_agent_id && delivery.parent_run_id == parent_run_id }) .collect::>(); deliveries.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); Ok(deliveries) } +pub(crate) fn claimed_static_delegate_deliveries_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + Ok( + static_delegate_deliveries_for_parent_at(root, parent_agent_id, parent_run_id)? + .into_iter() + .filter(|delivery| delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent) + .collect(), + ) +} + pub(crate) fn static_delegate_claim_exists_at( root: &Path, parent_agent_id: &str, @@ -1966,6 +1978,141 @@ pub(crate) fn write_static_delegate_delivery_at( ) } +pub(crate) fn replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + validate_static_delegate_delivery_record(expected)?; + let expected_result = expected + .structured_result + .as_ref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 structuredResult".to_string())?; + let (result_summary, structured_result) = + game_chat_safe_default_repair_replacement(expected_result) + .ok_or_else(|| "game-chat 安全默认返工转换状态无效".to_string())?; + let expected_is_original = + expected_result.contract_status == StaticDelegateContractStatus::NeedsUserInput; + let expected_is_converted = expected.result_summary.as_deref() == Some(result_summary.as_str()) + && expected_result == &structured_result; + if expected.status != StaticDelegateDeliveryStatus::ClaimedByParent + || expected.clarification_request_id.is_some() + || expected.clarification_answers_sha256.is_some() + || (!expected_is_original && !expected_is_converted) + { + return Err("game-chat 安全默认返工转换状态无效".to_string()); + } + validate_static_delegate_structured_result( + &structured_result, + expected.terminal_status.as_deref().unwrap_or_default(), + &expected.expected_artifacts, + )?; + let claim_action_id = expected + .claimed_by_action_id + .as_deref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 claim actionId".to_string())?; + let _claim_lock = acquire_static_delegate_claim_lock_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )?; + let _delivery_locks = + acquire_static_delegate_delivery_locks_at(root, vec![expected.delegation_id.clone()])?; + let mut delivery = read_static_delegate_delivery_at(root, &expected.delegation_id)? + .ok_or_else(|| { + format!( + "game-chat 安全默认返工 delivery 不存在:{}", + expected.delegation_id + ) + })?; + validate_static_delegate_delivery_identity(&delivery, expected)?; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.claimed_by_action_id.as_deref() != Some(claim_action_id) + || delivery.clarification_request_id.is_some() + || delivery.clarification_answers_sha256.is_some() + { + return Err("game-chat 安全默认返工 delivery 当前状态无效".to_string()); + } + let mut claim = read_static_delegate_claim_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少原 claim".to_string())?; + let receipt_index = claim + .receipts + .iter() + .position(|receipt| receipt.delegation_id == expected.delegation_id) + .ok_or_else(|| "game-chat 安全默认返工 claim 缺少原 delivery".to_string())?; + let receipt = &claim.receipts[receipt_index]; + if receipt.target_agent_id != delivery.target_agent_id + || receipt.status != delivery.terminal_status.as_deref().unwrap_or_default() + || receipt.acceptance_criteria != delivery.acceptance_criteria + || receipt.expected_artifacts != delivery.expected_artifacts + || receipt.repair_of_delegation_id != delivery.repair_of_delegation_id + { + return Err("game-chat 安全默认返工 claim 与 delivery 身份冲突".to_string()); + } + + let delivery_is_original = delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let delivery_is_converted = delivery.result_summary.as_deref() == Some(result_summary.as_str()) + && delivery.structured_result.as_ref() == Some(&structured_result); + let receipt_is_original = receipt.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let receipt_is_converted = receipt.summary == result_summary + && receipt.structured_result.as_ref() == Some(&structured_result); + if (!delivery_is_original && !delivery_is_converted) + || (!receipt_is_original && !receipt_is_converted) + { + return Err("game-chat 安全默认返工 delivery/claim 含非唯一转换差异".to_string()); + } + + if delivery_is_original { + let original = delivery + .structured_result + .as_ref() + .expect("original delivery result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 delivery 无法转换".to_string())?; + if converted != structured_result + || (receipt_is_original + && (receipt.summary != delivery.result_summary.as_deref().unwrap_or_default() + || receipt.structured_result.as_ref() != Some(original))) + { + return Err("game-chat 安全默认返工 claim 与原 delivery 结果冲突".to_string()); + } + } else if receipt_is_original { + let original = receipt + .structured_result + .as_ref() + .expect("original receipt result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 claim 无法转换".to_string())?; + if converted != structured_result { + return Err("game-chat 安全默认返工 converted delivery 与原 claim 冲突".to_string()); + } + } + + if !delivery_is_converted { + delivery.result_summary = Some(result_summary.clone()); + delivery.structured_result = Some(structured_result.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + } + if !receipt_is_converted { + let receipt = &mut claim.receipts[receipt_index]; + receipt.summary = result_summary; + receipt.structured_result = Some(structured_result); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(root, &claim)?; + } + Ok(delivery) +} + fn read_static_delegate_claim_at( root: &Path, parent_agent_id: &str, @@ -2571,6 +2718,140 @@ mod tests { assert!(error.contains("user.input_request")); } + #[test] + fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() { + let root = std::env::temp_dir().join(format!( + "genarrative-safe-default-half-state-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "project-1", "安全默认半状态恢复测试") + .expect("project init"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_run_id = "safe-default-half-state-parent-run"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + parent_agent_id, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + + for (suffix, convert_delivery_first) in [("delivery", true), ("claim", false)] { + let action_id = format!("safe-default-half-state-action-{suffix}"); + let delegation_id = format!("safe-default-half-state-delegation-{suffix}"); + let mut delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + "safe-default-half-state-session", + parent_run_id, + &format!("safe-default-half-state-parent-action-{suffix}"), + &delegation_id, + "code-prototype", + &format!("safe-default-half-state-child-session-{suffix}"), + &format!("safe-default-half-state-child-run-{suffix}"), + &["采用安全默认值继续".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let original = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "visual_style", + "header": "风格", + "question": "请选择视觉风格", + "options": [ + {"label": "明亮", "description": "使用明亮配色"}, + {"label": "柔和", "description": "使用柔和配色"} + ] + }] + }) + )), + ) + .expect("build needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要用户选择", + original.clone(), + ) + .expect("mark delivery ready"); + claim_ready_static_delegate_receipts_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("claim delivery"); + delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery") + .expect("delivery exists"); + let (summary, converted) = game_chat_safe_default_repair_replacement(&original) + .expect("convert safe default result"); + if convert_delivery_first { + delivery.result_summary = Some(summary.clone()); + delivery.structured_result = Some(converted.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(&root, &delivery).expect("write delivery half"); + } else { + let mut claim = read_static_delegate_claim_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("read claim") + .expect("claim exists"); + let receipt = claim + .receipts + .iter_mut() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("claim receipt"); + receipt.summary = summary.clone(); + receipt.structured_result = Some(converted.clone()); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(&root, &claim).expect("write claim half"); + } + + let reconciled = replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + &root, &delivery, + ) + .expect("reconcile half state"); + assert_eq!(reconciled.result_summary.as_deref(), Some(summary.as_str())); + assert_eq!(reconciled.structured_result.as_ref(), Some(&converted)); + let claim = + read_static_delegate_claim_at(&root, parent_agent_id, parent_run_id, &action_id) + .expect("read reconciled claim") + .expect("reconciled claim exists"); + let receipt = claim + .receipts + .iter() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("reconciled receipt"); + assert_eq!(receipt.summary, summary); + assert_eq!(receipt.structured_result.as_ref(), Some(&converted)); + } + + fs::remove_dir_all(root).ok(); + } + #[test] fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() { let root = std::env::temp_dir().join(format!( 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 79482de96..f5a657223 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -67,6 +67,7 @@ mod image_inspect; mod isolated_agent; mod mcp; mod patchset; +mod platform_session; mod preview; mod process_session; mod process_session_bridge; @@ -101,6 +102,7 @@ use image_inspect::*; use isolated_agent::*; use mcp::*; use patchset::*; +use platform_session::*; use preview::*; use process_session::*; use project::*; @@ -1237,10 +1239,10 @@ const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.jso const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server"; const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; -const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; -const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; +const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; -const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; +const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; @@ -1261,12 +1263,11 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 { fn default_game_creator_llm_tool_output_token_limit() -> u64 { DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT } - fn default_game_creator_planning_capability_enabled() -> bool { true } -const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; +const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800; @@ -1353,7 +1354,7 @@ impl Default for GameCreatorLlmConfig { model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, @@ -1877,6 +1878,11 @@ where } fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { + if matches!(event, tauri::RunEvent::Exit) { + if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { + eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + } + } match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { @@ -2205,10 +2211,6 @@ fn main() { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); } - #[cfg(all(debug_assertions, not(test)))] - if game_chat_launch.is_none() { - open_developer_window(app.handle())?; - } if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); } @@ -2228,6 +2230,7 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, + chat_with_game_creator_direct_codex, start_game_creator_agent_runtime_task, start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, @@ -2252,6 +2255,8 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, + install_platform_account_session, + clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, read_game_creator_mcp_catalog, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs new file mode 100644 index 000000000..eb7b6ba5b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -0,0 +1,389 @@ +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionSnapshot { + pub(crate) user_id: String, + pub(crate) access_token: String, + pub(crate) api_base_url: String, + pub(crate) generation: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EditorApiMode { + PlatformAccount, + ExternalDeveloper, +} + +pub(crate) fn editor_api_mode_for_build( + debug_assertions: bool, + game_chat_release_feature: bool, +) -> EditorApiMode { + if !debug_assertions && game_chat_release_feature { + EditorApiMode::ExternalDeveloper + } else { + EditorApiMode::PlatformAccount + } +} + +pub(crate) fn editor_api_mode() -> EditorApiMode { + editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release")) +} + +#[derive(Default)] +struct PlatformSessionState { + generation: u64, + snapshot: Option, +} + +static PLATFORM_SESSION: OnceLock> = OnceLock::new(); + +fn platform_session() -> &'static Mutex { + PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default())) +} + +fn install_platform_session_in( + current: &mut PlatformSessionState, + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) { + if generation < current.generation { + return; + } + if generation == current.generation { + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.user_id == user_id + && snapshot.access_token == access_token + && snapshot.api_base_url == api_base_url + }) { + return; + } + // Equal-generation retries may only repeat the exact committed snapshot. In + // particular, a late install cannot revive a generation that was cleared. + return; + } + current.generation = generation; + current.snapshot = Some(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url: api_base_url.to_string(), + generation, + }); +} + +fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { + if generation < current.generation { + return; + } + current.generation = generation; + current.snapshot = None; +} + +pub(crate) fn install_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + if editor_api_mode() == EditorApiMode::ExternalDeveloper { + return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string()); + } + let user_id = user_id.trim(); + let access_token = access_token.trim(); + let api_base_url = normalize_platform_api_base_url(api_base_url)?; + if user_id.is_empty() || user_id.len() > 256 { + return Err("陶泥儿登录用户身份无效".to_string()); + } + if access_token.is_empty() || access_token.len() > 16 * 1024 { + return Err("陶泥儿登录凭据无效".to_string()); + } + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + install_platform_session_in( + &mut current, + user_id, + access_token, + &api_base_url, + generation, + ); + Ok(()) +} + +fn normalize_platform_api_base_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?; + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if parsed.scheme() == "http" && !loopback { + return Err("陶泥儿服务地址不在受信任白名单内".to_string()); + } + Ok(value.to_string()) +} + +pub(crate) fn clear_platform_session(generation: u64) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear_platform_session_in(&mut current, generation); +} + +pub(crate) fn current_platform_session() -> Option { + platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .snapshot + .clone() +} + +pub(crate) fn validate_platform_session_snapshot( + expected: &PlatformSessionSnapshot, +) -> Result<(), String> { + if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + Ok(()) + } else { + Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ) + } +} + +fn platform_session_snapshot_matches( + current: Option<&PlatformSessionSnapshot>, + expected: &PlatformSessionSnapshot, +) -> bool { + current == Some(expected) +} + +pub(crate) fn platform_session_is_available() -> bool { + current_platform_session().is_some() +} + +pub(crate) fn platform_session_service_identity() -> Option { + current_platform_session() + .map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id)) +} + +#[cfg(test)] +static PLATFORM_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub(crate) struct TestPlatformSessionGuard { + _isolation: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +#[cfg(test)] +impl Drop for TestPlatformSessionGuard { + fn drop(&mut self) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *current = self + .previous + .take() + .expect("platform session test guard must restore its prior state"); + } +} + +/// Installs a scoped platform-account session for tests that must exercise the +/// production-default client authentication path. The previous process state +/// is restored on drop so the fixture cannot leak credentials or account +/// identity to another test. +#[cfg(test)] +pub(crate) fn install_test_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, +) -> TestPlatformSessionGuard { + let isolation = PLATFORM_SESSION_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::mem::take(&mut *current); + *current = PlatformSessionState { + generation: 1, + snapshot: Some(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url: api_base_url.to_string(), + generation: 1, + }), + }; + drop(current); + TestPlatformSessionGuard { + _isolation: isolation, + previous: Some(previous), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleared_generation_rejects_late_install_and_older_clear() { + let mut state = PlatformSessionState::default(); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 1, + ); + clear_platform_session_in(&mut state, 2); + install_platform_session_in( + &mut state, + "user-a", + "late-token-a", + "https://dev.genarrative.world", + 1, + ); + install_platform_session_in( + &mut state, + "user-a", + "same-generation-token", + "https://dev.genarrative.world", + 2, + ); + assert!(state.snapshot.is_none()); + assert_eq!(state.generation, 2); + + install_platform_session_in( + &mut state, + "user-b", + "token-b", + "https://dev.genarrative.world", + 3, + ); + clear_platform_session_in(&mut state, 2); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + assert_eq!(state.generation, 3); + } + + #[test] + fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + let mut state = PlatformSessionState::default(); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 4, + ); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 4, + ); + install_platform_session_in( + &mut state, + "user-b", + "token-b", + "https://dev.genarrative.world", + 4, + ); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-a") + ); + assert_eq!( + state + .snapshot + .as_ref() + .map(|value| value.access_token.as_str()), + Some("token-a") + ); + } + + #[test] + fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() { + assert_eq!( + editor_api_mode_for_build(true, false), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(true, true), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(false, false), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(false, true), + EditorApiMode::ExternalDeveloper + ); + } + + #[test] + fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() { + assert_eq!( + normalize_platform_api_base_url("https://staging.example.com/"), + Ok("https://staging.example.com".to_string()) + ); + assert_eq!( + normalize_platform_api_base_url("http://127.0.0.1:8080/"), + Ok("http://127.0.0.1:8080".to_string()) + ); + assert!(normalize_platform_api_base_url("http://staging.example.com").is_err()); + assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err()); + } + + #[test] + fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { + let expected = PlatformSessionSnapshot { + user_id: "user-a".to_string(), + access_token: "token-a".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + generation: 4, + }; + assert!(platform_session_snapshot_matches( + Some(&expected), + &expected + )); + + for current in [ + None, + Some(PlatformSessionSnapshot { + user_id: "user-b".to_string(), + ..expected.clone() + }), + Some(PlatformSessionSnapshot { + access_token: "token-b".to_string(), + generation: 5, + ..expected.clone() + }), + ] { + assert!(!platform_session_snapshot_matches( + current.as_ref(), + &expected + )); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 056d25ce3..f8846b851 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -590,10 +590,18 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result, #[serde(default)] + platform_owner_user_id: Option, + #[serde(default)] + platform_owner_api_base_url: Option, + #[serde(default)] service_identity_confirmation: Option, canvas_context: Option, endpoint: Option, @@ -312,11 +316,14 @@ struct ReferenceMaterial { media_type: Option, file_name: Option, sha256: Option, + width: Option, + height: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReferencePreparationError { AuthenticationRequired, + PermissionDenied, MaterialInvalid, TicketFailed, ObjectUploadFailed, @@ -328,6 +335,7 @@ impl ReferencePreparationError { fn code(self) -> &'static str { match self { Self::AuthenticationRequired => "authentication-required", + Self::PermissionDenied => "permission-denied", Self::MaterialInvalid => "reference-material-invalid", Self::TicketFailed => "reference-ticket-failed", Self::ObjectUploadFailed => "reference-object-upload-failed", @@ -337,8 +345,10 @@ impl ReferencePreparationError { } fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") || error.contains("HTTP 403") { + if error.contains("HTTP 401") { Self::AuthenticationRequired + } else if error.contains("HTTP 403") { + Self::PermissionDenied } else { fallback } @@ -362,11 +372,48 @@ impl CanvasGenerationApiMode { } fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), String> { - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; + let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; Ok((api_base_url, CanvasGenerationApiMode { api_key })) } +fn bind_generation_platform_owner( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + let Some(session) = session else { + return Ok(()); + }; + match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (None, None) if ledger.operation_id.is_none() && ledger.remote_result.is_none() => { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + write_generation_ledger(root, ledger) + } + (Some(user_id), Some(api_base_url)) + if user_id == session.user_id && api_base_url == session.api_base_url => + { + Ok(()) + } + _ => Err( + "authentication-required: 此素材生成属于其他陶泥儿账号,当前账号不能恢复或提交" + .to_string(), + ), + } +} + +fn ensure_frozen_generation_platform_session( + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + if let Some(session) = session { + validate_platform_session_snapshot(session)?; + } + Ok(()) +} + fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { let _ = mode; platform_art_generation_external_service_fingerprint(api_base_url) @@ -1041,6 +1088,8 @@ fn validate_and_prepare_ledger( resolved_reference_ids: Vec::new(), api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: None, endpoint: None, @@ -1076,6 +1125,10 @@ fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Optio .map(str::to_string) } +fn is_registered_editor_reference_id(value: &str) -> bool { + value.starts_with("editor-resource-") || value.starts_with("editor-asset-") +} + fn reference_material_at( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -1090,16 +1143,22 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - if let Some(stable_reference) = stable_manifest_reference(asset) { + if let Some(stable_reference) = stable_manifest_reference(asset).filter(|reference| { + ledger.intent != AssetCanvasIntent::Refine + || ledger.source_resource_id.as_deref() != Some(resource_id) + || is_registered_editor_reference_id(reference) + }) { return Ok(ReferenceMaterial { stable_reference: Some(stable_reference), bytes: None, media_type: None, file_name: None, sha256: None, + width: None, + height: None, }); } - let (bytes, _, _) = open_and_validate_image_file( + let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &asset.local_path)?, &asset.media_type, None, @@ -1112,6 +1171,8 @@ fn reference_material_at( media_type: Some(asset.media_type.clone()), file_name: Some(format!("reference-{sha256}.{extension}")), sha256: Some(sha256), + width: Some(width), + height: Some(height), }); } let layer = draft @@ -1122,7 +1183,7 @@ fn reference_material_at( .ok_or_else(|| "参考资源不属于当前草稿或项目 manifest".to_string())?; let (relative_path, media_type, expected_sha256) = draft_media_relative_path(&draft.draft_id, &layer.media_ref)?; - let (bytes, _, _) = open_and_validate_image_file( + let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &relative_path)?, &media_type, expected_sha256.as_deref(), @@ -1135,6 +1196,8 @@ fn reference_material_at( media_type: Some(media_type), file_name: Some(format!("reference-{sha256}.{extension}")), sha256: Some(sha256), + width: Some(width), + height: Some(height), }) } @@ -1147,7 +1210,7 @@ async fn try_confirm_uploaded_reference( material: &ReferenceMaterial, asset_kind: &str, ) -> Result, ReferencePreparationError> { - let endpoint = "/api/external/v1/assets/objects/confirm"; + let endpoint = resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm"); let response = authorize_canvas_request( client .post(format!("{api_base_url}{endpoint}")) @@ -1165,12 +1228,12 @@ async fn try_confirm_uploaded_reference( .send() .await .map_err(|_| ReferencePreparationError::ConfirmFailed)?; - if matches!( - response.status(), - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) { + if matches!(response.status(), reqwest::StatusCode::UNAUTHORIZED) { return Err(ReferencePreparationError::AuthenticationRequired); } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(ReferencePreparationError::PermissionDenied); + } if !response.status().is_success() { return Ok(None); } @@ -1199,7 +1262,8 @@ async fn request_upload_ticket( ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, ) -> Result { - let endpoint = "/api/external/v1/assets/direct-upload-tickets"; + let endpoint = + resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets"); let payload = external_editor_json_request( authorize_canvas_request( client @@ -1313,6 +1377,62 @@ async fn upload_reference( Ok(()) } +async fn register_uploaded_image_reference( + client: &reqwest::Client, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + ledger: &AssetCanvasGenerationLedger, + material: &ReferenceMaterial, + object_key: &str, + asset_object_id: &str, +) -> Result { + let context = ledger + .canvas_context + .as_ref() + .ok_or(ReferencePreparationError::Internal)?; + let width = material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?; + let height = material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?; + let endpoint = format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(&context.project_id) + ); + let payload = external_editor_json_request( + authorize_canvas_request( + client + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(&endpoint) + )) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", object_key.trim_start_matches('/')), + "objectKey": object_key, + "assetObjectId": asset_object_id, + "width": width, + "height": height, + "sourceType": "uploaded", + "assetKind": ledger.asset_kind, + "generationInputs": { + "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, + "operationId": ledger.generation_id, + }, + })), + api_mode, + ), + "登记参考图片项目资源", + ) + .await + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::ConfirmFailed) + })?; + let data = external_editor_response_data(&payload); + let resource = data.get("resource").unwrap_or(data); + json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) +} + async fn ensure_reference_states( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1350,7 +1470,20 @@ async fn ensure_reference_states( } }; if ledger.reference_states[index].stable_reference.is_some() { - continue; + let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); + if !requires_registered_source + || ledger.reference_states[index] + .stable_reference + .as_deref() + .is_some_and(is_registered_editor_reference_id) + { + continue; + } + // A legacy prepared refine ledger may carry an objectKey. It cannot + // be sent as sourceReferenceId, so re-register it before submission. + ledger.reference_states[index].stable_reference = None; + write_generation_ledger(root, ledger)?; } if let Some(stable_reference) = material.stable_reference.clone() { ledger.reference_states[index].stable_reference = Some(stable_reference); @@ -1373,7 +1506,23 @@ async fn ensure_reference_states( ) .await? { - ledger.reference_states[index].stable_reference = Some(object_key); + let stable_reference = if ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) + { + register_uploaded_image_reference( + client, + api_base_url, + api_mode, + ledger, + &material, + &object_key, + &asset_object_id, + ) + .await? + } else { + object_key + }; + ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; ledger.reference_states[index].upload_object_key = None; @@ -1402,7 +1551,23 @@ async fn ensure_reference_states( ) .await? .ok_or(ReferencePreparationError::ConfirmFailed)?; - ledger.reference_states[index].stable_reference = Some(ticket.object_key); + let stable_reference = if ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) + { + register_uploaded_image_reference( + client, + api_base_url, + api_mode, + ledger, + &material, + &ticket.object_key, + &asset_object_id, + ) + .await? + } else { + ticket.object_key + }; + ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; ledger.reference_states[index].upload_object_key = None; @@ -1487,7 +1652,7 @@ fn build_generation_request_snapshot( "operationId": ledger.generation_id, }), ); - let source_image_src = ledger + let source_reference_id = ledger .source_resource_id .as_ref() .and_then(|resource_id| { @@ -1497,30 +1662,15 @@ fn build_generation_request_snapshot( .find(|state| state.resource_id == *resource_id) }) .and_then(|state| state.stable_reference.clone()) - .ok_or_else(|| "精修生成缺少稳定源图片引用".to_string())?; + .ok_or_else(|| "精修生成缺少正式源资源 ID".to_string())?; body.insert( - "sourceImageSrc".to_string(), - serde_json::json!(source_image_src), + "sourceReferenceId".to_string(), + serde_json::json!(source_reference_id), ); - if let Some(source_resource_id) = ledger - .source_resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - }) - { - body.insert( - "sourceResourceId".to_string(), - serde_json::json!(source_resource_id), - ); - } let additional = ledger .resolved_reference_ids .iter() - .filter(|reference| **reference != source_image_src) + .filter(|reference| **reference != source_reference_id) .cloned() .collect::>(); body.insert( @@ -1654,10 +1804,19 @@ fn mark_generation_error( fn sanitized_generation_error(code: &str) -> String { match code { - "configuration-missing" => "External Editor API 配置缺失,未创建任何资源".to_string(), - "authentication-required" => { - "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() - } + "configuration-missing" => match editor_api_mode() { + EditorApiMode::PlatformAccount => "陶泥儿登录态缺失,请先登录后再生成图片".to_string(), + EditorApiMode::ExternalDeveloper => { + "External Editor API 配置缺失,未创建任何资源".to_string() + } + }, + "authentication-required" => match editor_api_mode() { + EditorApiMode::PlatformAccount => "陶泥儿登录已失效,请重新登录".to_string(), + EditorApiMode::ExternalDeveloper => { + "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() + } + }, + "permission-denied" => editor_api_authorization_error(), "insufficient-mud-points" => "泥点余额不足,请充值后重试".to_string(), "platform-service-configuration" => { "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() @@ -1683,7 +1842,15 @@ fn sanitized_generation_error(code: &str) -> String { fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String { if reconciliation && code == "authentication-required" { - return "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string(); + return match editor_api_mode() { + EditorApiMode::PlatformAccount => { + "reconciliation-required: 陶泥儿登录态已失效;重新登录后使用原 operation 恢复" + .to_string() + } + EditorApiMode::ExternalDeveloper => { + "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string() + } + }; } sanitized_generation_error(code) } @@ -1732,12 +1899,12 @@ async fn wait_for_canvas_generation_result( async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { let status = response.status(); - if matches!( - status, - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) { + if matches!(status, reqwest::StatusCode::UNAUTHORIZED) { return ("authentication-required", false); } + if status == reqwest::StatusCode::FORBIDDEN { + return ("permission-denied", false); + } let body = response.text().await.unwrap_or_default(); if status == reqwest::StatusCode::CONFLICT && body.contains("泥点余额不足") { return ("insufficient-mud-points", false); @@ -1761,6 +1928,9 @@ fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { if error.contains("authentication-required") { return (true, "authentication-required"); } + if error.contains("permission-denied") { + return (false, "permission-denied"); + } if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") { return (false, "insufficient-mud-points"); } @@ -1789,6 +1959,7 @@ async fn reconcile_generation( mut ledger: AssetCanvasGenerationLedger, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result { if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { @@ -1799,6 +1970,8 @@ async fn reconcile_generation( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } + 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); match prepare_generation_service_identity(root, &mut ledger, api_base_url, api_mode) { Ok(CanvasServiceIdentityDecision::Ready) => {} @@ -1911,6 +2084,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; + ensure_frozen_generation_platform_session(platform_session)?; let status = response.status(); if !status.is_success() { let (code, reconciliation) = classify_canvas_submit_error(response).await; @@ -1945,7 +2119,10 @@ async fn reconcile_generation( ) .await { - Ok(result) => result, + Ok(result) => { + ensure_frozen_generation_platform_session(platform_session)?; + result + } Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; @@ -1971,7 +2148,10 @@ async fn reconcile_generation( publish_public_phase(root, &mut ledger, emit)?; match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await { - Ok(result) => result, + Ok(result) => { + ensure_frozen_generation_platform_session(platform_session)?; + result + } Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; @@ -2026,7 +2206,10 @@ async fn reconcile_generation( ) .await; let download = match download_result { - Ok(Some(download)) => download, + Ok(Some(download)) => { + ensure_frozen_generation_platform_session(platform_session)?; + download + } _ => { mark_generation_error( root, @@ -2080,6 +2263,7 @@ async fn reconcile_generation( } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + ensure_frozen_generation_platform_session(platform_session)?; if !synchronize_staged_image_revision(root, &mut ledger)? { mark_generation_error( root, @@ -2255,15 +2439,25 @@ pub(crate) async fn generate_asset_canvas_image_at( let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; let mut ledger = validate_and_prepare_ledger(root, input)?; - let (api_base_url, api_mode) = match resolve_generation_api_mode() { - Ok(value) => value, - Err(_) => { - let code = "configuration-missing"; - mark_generation_error(root, &mut ledger, false, code, &mut emit)?; - return Err(sanitized_generation_error(code)); - } - }; - reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await + let (api_base_url, api_key, platform_session) = + match resolve_canvas_sync_api_credentials(None, None) { + Ok(value) => value, + Err(_) => { + let code = "configuration-missing"; + mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + return Err(sanitized_generation_error(code)); + } + }; + let api_mode = CanvasGenerationApiMode { api_key }; + reconcile_generation( + root, + ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + &mut emit, + ) + .await } pub(crate) async fn recover_asset_canvas_generations_at( @@ -2314,8 +2508,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } - let (api_base_url, api_mode) = resolve_generation_api_mode() + let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; + let api_mode = CanvasGenerationApiMode { api_key }; let mut resumed = Vec::new(); let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); @@ -2354,7 +2549,19 @@ pub(crate) async fn recover_asset_canvas_generations_at( Err(_) => {} } resumed.push(generation_id.clone()); - match reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await { + if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { + continue; + } + match reconcile_generation( + root, + ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + &mut emit, + ) + .await + { Ok(execution) => { if let Some(event) = execution.event { events.push(event); @@ -2450,10 +2657,26 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( mod tests { use super::*; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::future::Future; use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; + async fn with_test_external_editor_credentials( + api_base_url: &str, + api_key: &str, + operation: impl Future, + ) -> T { + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + api_base_url.to_string(), + api_key.to_string(), + ), + operation, + ) + .await + } + fn test_png_with_color(color: [u8; 4]) -> Vec { let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); @@ -2500,6 +2723,32 @@ mod tests { String::from_utf8_lossy(&bytes).into_owned() } + fn accept_generation_fixture_connection( + listener: &TcpListener, + fixture: &str, + request_index: usize, + ) -> TcpStream { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore generation fixture stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {fixture} request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept {fixture} request {request_index}: {error}"), + } + } + } + fn write_json(stream: &mut TcpStream, status: &str, body: serde_json::Value) { let body = body.to_string(); write!( @@ -2670,6 +2919,8 @@ mod tests { api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), ), + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), @@ -2708,8 +2959,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..6 { - let (mut stream, _) = listener.accept().expect("accept generation request"); + listener + .set_nonblocking(true) + .expect("set generation fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "generation fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -2776,16 +3034,15 @@ mod tests { } }); let api_key = "phase-five-secret-api-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let private_prompt = "只允许进入私有账本的完整提示词正文"; let input = generation_input(directory.path(), project_id, &draft, private_prompt); let mut progress = Vec::new(); - let first = + let first = with_test_external_editor_credentials(&base_url, api_key, async { generate_asset_canvas_image_at(directory.path(), &input, |event| progress.push(event)) .await - .expect("generate and commit image"); + }) + .await + .expect("generate and commit image"); server.join().expect("join generation server"); assert_eq!( @@ -2843,9 +3100,11 @@ mod tests { .to_ascii_lowercase() .contains(&format!("idempotency-key: {}", input.idempotency_key))); - let replay = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .expect("replay committed generation"); + let replay = with_test_external_editor_credentials(&base_url, api_key, async { + generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("replay committed generation"); assert_eq!(replay.result.commit, first.result.commit); assert!(replay.event.is_none()); @@ -2853,10 +3112,12 @@ mod tests { generation_input(directory.path(), project_id, &draft, private_prompt); conflicting.intent_id = input.intent_id.clone(); conflicting.idempotency_key = input.idempotency_key.clone(); - let conflict = generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}) - .await - .err() - .expect("reject identity reuse under another generation"); + let conflict = with_test_external_editor_credentials(&base_url, api_key, async { + generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}).await + }) + .await + .err() + .expect("reject identity reuse under another generation"); assert!(conflict.contains("不同 generationId")); let public_draft = read_asset_canvas_draft_at( @@ -2927,6 +3188,10 @@ mod tests { let upload_url = format!("{base_url}/oss-upload"); let signed_url = format!("{base_url}/refined.png"); let result_png = test_png_with_color([218, 42, 64, 255]); + let source_image = + image::load_from_memory(&source_png).expect("decode refine source image"); + let source_width = source_image.width(); + let source_height = source_image.height(); let mut input = generation_input( directory.path(), project_id, @@ -2945,8 +3210,15 @@ mod tests { let source_png_len = source_png.len(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..9 { - let (mut stream, _) = listener.accept().expect("accept refine generation request"); + listener + .set_nonblocking(true) + .expect("set refine generation fixture nonblocking"); + for request_index in 0..10 { + let mut stream = accept_generation_fixture_connection( + &listener, + "refine generation fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -2998,6 +3270,19 @@ mod tests { "assetObjectId": "confirmed-reference-object" }}}), ); + } else if request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-source", + "projectId": "remote-project", + "objectKey": server_object_key.clone(), + "assetObjectId": "confirmed-reference-object" + }}}), + ); } else if request.starts_with("POST /api/external/v1/editor/images/edits ") { write_json( &mut stream, @@ -3037,21 +3322,16 @@ mod tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "refine-private-api-key" - }}) - .to_string(), - ); - - let execution = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + let execution = + with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { + generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await + }) .await .expect("upload local source and complete refine generation"); server.join().expect("join refine generation server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 9); + assert_eq!(requests.len(), 10); let ticket_index = requests .iter() .position(|request| { @@ -3072,16 +3352,27 @@ mod tests { .iter() .position(|request| request.starts_with("POST /api/external/v1/editor/images/edits ")) .expect("refine submit request exists"); + let register_index = requests + .iter() + .position(|request| { + request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + }) + .expect("project resource registration exists"); assert!(ticket_index < upload_index && upload_index < confirm_index); - assert!(confirm_index < submit_index); + assert!(confirm_index < register_index && register_index < submit_index); assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); assert!(requests[ticket_index].contains(&format!( "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", project_id, draft.draft_id, input.generation_id ))); assert!(requests[confirm_index].contains(&expected_object_key)); - assert!(requests[submit_index] - .contains(&format!("\"sourceImageSrc\":\"{}\"", expected_object_key))); + assert!(requests[register_index] + .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); + assert!(requests[register_index].contains(&format!("\"width\":{source_width}"))); + assert!(requests[register_index].contains(&format!("\"height\":{source_height}"))); + assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); + assert!(!requests[submit_index].contains("sourceImageSrc")); assert!(!requests[submit_index].contains("sourceResourceId")); let manifest = execution.result.commit.manifest; @@ -3114,7 +3405,10 @@ mod tests { .expect("read refine ledger") .expect("refine ledger exists"); assert_eq!(ledger.operation_id.as_deref(), Some("refine-operation")); - assert_eq!(ledger.resolved_reference_ids, vec![expected_object_key]); + assert_eq!( + ledger.resolved_reference_ids, + vec!["editor-resource-source".to_string()] + ); assert!(ledger.reference_states.iter().all(|state| { state.upload_bucket.is_none() && state.upload_object_key.is_none() @@ -3134,8 +3428,15 @@ mod tests { ); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..3 { - let (mut stream, _) = listener.accept().expect("accept ticket failure request"); + listener + .set_nonblocking(true) + .expect("set ticket failure fixture nonblocking"); + for request_index in 0..3 { + let mut stream = accept_generation_fixture_connection( + &listener, + "ticket failure fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3170,13 +3471,6 @@ mod tests { } } }); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "ticket-failure-private-api-key" - }}) - .to_string(), - ); let input = generation_input( directory.path(), project_id, @@ -3184,10 +3478,14 @@ mod tests { "这段提示词不得进入公开错误", ); - let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .err() - .expect("ticket failure must stop generation"); + let error = with_test_external_editor_credentials( + &base_url, + "ticket-failure-private-api-key", + async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await }, + ) + .await + .err() + .expect("ticket failure must stop generation"); server.join().expect("join ticket failure server"); assert_eq!(error, "参考资源上传凭证申请失败,未提交生成"); @@ -3263,8 +3561,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..3 { - let (mut stream, _) = listener.accept().expect("accept recovery request"); + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + for request_index in 0..3 { + let mut stream = accept_generation_fixture_connection( + &listener, + "accepted recovery fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3301,9 +3606,6 @@ mod tests { }); let old_api_key = "phase-five-recovery-old-key"; let api_key = "phase-five-recovery-rotated-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3321,9 +3623,11 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let blocked = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("request explicit service identity confirmation"); + let blocked = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("request explicit service identity confirmation"); assert!(blocked.result.resumed_generation_ids.is_empty()); assert_eq!(blocked.result.service_identity_confirmations.len(), 1); assert!(receiver.try_recv().is_err(), "确认前不得访问网络"); @@ -3333,17 +3637,20 @@ mod tests { assert_eq!(confirmation.operation_state, "accepted"); assert_eq!(confirmation.service_origin, base_url); - let confirmed = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: confirmation.challenge, - }, - ) + let confirmed = with_test_external_editor_credentials(&base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: confirmation.challenge, + }, + ) + .await + }) .await .expect("confirm the current service for the legacy operation"); assert_eq!( @@ -3351,9 +3658,11 @@ mod tests { PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME ); - let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("recover accepted generation after confirmation"); + let first = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("recover accepted generation after confirmation"); server.join().expect("join recovery server"); assert_eq!(first.result.resumed_generation_ids, vec![generation_id]); assert!(first.result.service_identity_confirmations.is_empty()); @@ -3362,9 +3671,11 @@ mod tests { assert_eq!(requests.len(), 3); assert!(requests.iter().all(|request| request.starts_with("GET "))); - let second = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) - .await - .expect("ignore already committed generation"); + let second = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("ignore already committed generation"); assert!(second.result.resumed_generation_ids.is_empty()); assert!(second.result.service_identity_confirmations.is_empty()); assert!(second.events.is_empty()); @@ -3378,10 +3689,7 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "服务身份陈旧挑战测试"); let base_url = "https://editor.example.test"; let old_api_key = "old-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": "rotated-key"}}) - .to_string(), - ); + let api_key = "rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3398,9 +3706,11 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let first = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("issue first challenge"); + let first = with_test_external_editor_credentials(base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("issue first challenge"); let first_confirmation = first.result.service_identity_confirmations[0].clone(); let mut expired = read_generation_ledger(directory.path(), &generation_id) .expect("read ledger") @@ -3411,41 +3721,49 @@ mod tests { .expect("confirmation exists") .expires_at = asset_canvas_now(); write_generation_ledger(directory.path(), &mut expired).expect("expire challenge"); - let expired_error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: first_confirmation.challenge, - }, - ) + let expired_error = with_test_external_editor_credentials(base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: first_confirmation.challenge, + }, + ) + .await + }) .await .expect_err("expired challenge must fail closed"); assert!(expired_error.contains("过期或上下文已变化")); - let second = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("rotate expired challenge"); + let second = with_test_external_editor_credentials(base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("rotate expired challenge"); let second_confirmation = second.result.service_identity_confirmations[0].clone(); let mut changed = read_generation_ledger(directory.path(), &generation_id) .expect("read ledger") .expect("ledger exists"); changed.phase = GenerationLedgerPhase::Running; write_generation_ledger(directory.path(), &mut changed).expect("change operation state"); - let stale_error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: second_confirmation.challenge, - }, - ) + let stale_error = with_test_external_editor_credentials(base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id, + operation_id, + challenge: second_confirmation.challenge, + }, + ) + .await + }) .await .expect_err("ledger changes must invalidate the challenge"); assert!(stale_error.contains("过期或上下文已变化")); @@ -3457,13 +3775,7 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "服务身份地址变化测试"); let original_base_url = "https://editor.example.test"; let old_api_key = "old-key"; - let config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": original_base_url, - "apiKey": "rotated-key" - }}) - .to_string(), - ); + let original_api_key = "rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, original_base_url, old_api_key); ledger.api_identity_scheme = None; ledger.api_identity_fingerprint = Some( @@ -3475,35 +3787,38 @@ mod tests { let generation_id = ledger.generation_id.clone(); let operation_id = ledger.operation_id.clone(); write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); - let blocked = 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.clone(), - }, - |_| {}, - ) - .await - .expect("issue service challenge"); + let blocked = + with_test_external_editor_credentials(original_base_url, original_api_key, async { + 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.clone(), + }, + |_| {}, + ) + .await + }) + .await + .expect("issue service challenge"); let confirmation = blocked.result.service_identity_confirmations[0].clone(); - drop(config_guard); - let _changed_config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": "https://replacement-editor.example.test", - "apiKey": "replacement-key" - }}) - .to_string(), - ); - let error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: confirmation.challenge, + let error = with_test_external_editor_credentials( + "https://replacement-editor.example.test", + "replacement-key", + async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id, + operation_id, + challenge: confirmation.challenge, + }, + ) + .await }, ) .await @@ -3526,8 +3841,15 @@ mod tests { let png = test_png(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..4 { - let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + listener + .set_nonblocking(true) + .expect("set prepared recovery fixture nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "prepared recovery fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -3573,13 +3895,7 @@ mod tests { } }); let old_api_key = "prepared-old-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "prepared-rotated-key" - }}) - .to_string(), - ); + let api_key = "prepared-rotated-key"; let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); ledger.phase = GenerationLedgerPhase::Prepared; ledger.operation_id = None; @@ -3603,28 +3919,34 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; - let blocked = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("request prepared service confirmation"); + let blocked = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("request prepared service confirmation"); assert!(receiver.try_recv().is_err(), "确认前不得提交 prepared 请求"); let confirmation = blocked.result.service_identity_confirmations[0].clone(); - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id: generation_id.clone(), - operation_id: None, - challenge: confirmation.challenge, - }, - ) + with_test_external_editor_credentials(&base_url, api_key, async { + confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + generation_id: generation_id.clone(), + operation_id: None, + challenge: confirmation.challenge, + }, + ) + .await + }) .await .expect("confirm prepared service identity"); - let recovered = - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) - .await - .expect("replay prepared request"); + let recovered = with_test_external_editor_credentials(&base_url, api_key, async { + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await + }) + .await + .expect("replay prepared request"); server.join().expect("join prepared recovery server"); assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); @@ -3698,7 +4020,7 @@ mod tests { reference_states: vec![ PrivateReferenceState { resource_id: source_resource_id.clone(), - stable_reference: Some("objects/source.png".to_string()), + stable_reference: Some("editor-resource-source".to_string()), asset_object_id: Some("source-object".to_string()), upload_bucket: None, upload_object_key: None, @@ -3714,11 +4036,13 @@ mod tests { }, ], resolved_reference_ids: vec![ - "objects/source.png".to_string(), + "editor-resource-source".to_string(), "objects/style.png".to_string(), ], api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), @@ -3744,7 +4068,8 @@ mod tests { let request: serde_json::Value = serde_json::from_str(&request).expect("parse refine request"); assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); - assert_eq!(request["sourceImageSrc"], "objects/source.png"); + assert_eq!(request["sourceReferenceId"], "editor-resource-source"); + assert!(request.get("sourceImageSrc").is_none()); assert!(request.get("sourceResourceId").is_none()); assert_eq!( request["referenceImageSrcs"], @@ -3890,20 +4215,11 @@ mod tests { )); } - #[test] - fn external_api_key_is_private_and_canvas_routes_are_external_only() { + #[tokio::test] + async fn external_api_key_is_private_and_canvas_routes_are_external_only() { let project_id = "phase-five-external-key"; let (directory, draft) = create_generation_fixture(project_id, "阶段五 External 凭据测试"); let secret = "developer-api-key-must-never-be-persisted"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({ - "editorApi": { - "baseUrl": "http://127.0.0.1:9", - "apiKey": secret - } - }) - .to_string(), - ); let input = generation_input( directory.path(), project_id, @@ -3911,10 +4227,15 @@ mod tests { "External 凭据不能进入私有账本", ); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); - let (api_base_url, api_mode) = - resolve_generation_api_mode().expect("resolve external mode"); + let (mut ledger, api_base_url, api_mode) = + with_test_external_editor_credentials("http://127.0.0.1:9", secret, async { + let ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); + let (api_base_url, api_mode) = + resolve_generation_api_mode().expect("resolve external mode"); + (ledger, api_base_url, api_mode) + }) + .await; assert_eq!(api_mode.bearer_token(), secret); ledger.canvas_context = Some(PrivateCanvasContext { project_id: "external-project".to_string(), @@ -3972,20 +4293,16 @@ mod tests { } #[tokio::test] - async fn missing_configuration_fails_explicitly_without_creating_an_asset() { + async fn missing_platform_login_fails_explicitly_without_creating_an_asset() { let project_id = "phase-five-missing-config"; let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": "http://127.0.0.1:9", "apiKey": ""}}) - .to_string(), - ); let prompt = "配置缺失时也不能公开的正文"; let input = generation_input(directory.path(), project_id, &draft, prompt); let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) .await .err() .expect("missing configuration must fail"); - assert!(error.contains("配置缺失")); + assert!(error.contains("陶泥儿登录态缺失")); assert!(!error.contains(prompt)); assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index e4561938a..ac8f60e2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -97,16 +97,42 @@ fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { false } +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_treats_windows_target_races_as_contention() { + for code in [5, 32, 33] { + assert!( + project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)), + "Windows project lock error {code} must enter the bounded contention wait" + ); + } +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, ) -> Result { validate_project_root(root)?; - let path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + let mut path = resolve_project_write_lock_path(root)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root)?; let payload = serde_json::json!({ "commandId": command_id, "pid": std::process::id(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 143013c3e..851c1899e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -60,62 +60,30 @@ fn inspect_godot_project_marker(root: &Path) -> Result { } #[cfg(windows)] { - // 不能用 `std::os::windows::fs::MetadataExt::number_of_links`:它属于未稳定的 - // `windows_by_handle` feature,而本仓库 `rust-toolchain.toml` 锁在 stable - // 1.96.0,会 E0658 编译失败。**这段 `#[cfg(windows)]` 在 Linux CI 上根本不参与 - // 编译,所以 CI 绿不代表它能构建。** 仓库既有做法是自己走 - // GetFileInformationByHandle,见 `runner/endpoint.rs` 与 - // `tool_plan_handoff/storage_windows.rs`;此处沿用同一写法。 - use std::ffi::c_void; use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; - #[repr(C)] - struct FileTime { - low_date_time: u32, - high_date_time: u32, - } - #[repr(C)] - struct ByHandleFileInformation { - file_attributes: u32, - creation_time: FileTime, - last_access_time: FileTime, - last_write_time: FileTime, - volume_serial_number: u32, - file_size_high: u32, - file_size_low: u32, - number_of_links: u32, - file_index_high: u32, - file_index_low: u32, - } - #[link(name = "kernel32")] - unsafe extern "system" { - fn GetFileInformationByHandle( - file: *mut c_void, - information: *mut ByHandleFileInformation, - ) -> i32; - } - - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; let file = fs::File::open(&project_file).map_err(|error| { format!( - "读取 Godot 项目文件失败:{}: {error}", + "打开 Godot 项目文件失败:{}: {error}", project_file.display() ) })?; // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live kernel handle and information is a valid output pointer. - // 取不到句柄信息与「确实是硬链接」一样按拒绝处理,与原 `!= Some(1)` 的 - // fail-closed 语义一致。 + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 - || information.file_attributes - & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) - != 0 - || information.number_of_links != 1 { return Err(format!( - "Godot 项目文件必须可确认是无硬链接普通文件:{}", + "读取 Godot 项目文件 Windows 身份失败:{}", + std::io::Error::last_os_error() + )); + } + if information.nNumberOfLinks != 1 { + return Err(format!( + "Godot 项目文件不能是硬链接文件:{}", project_file.display() )); } @@ -600,9 +568,28 @@ pub(crate) fn record_command_run( pub(crate) fn read_manifest_for_project(root: &Path) -> Result { let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + let canonical_manifest_installed = match fs::symlink_metadata(&manifest_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("manifest 必须是普通文件".to_string()); + } + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(format!( + "读取 manifest 元数据失败:{}: {error}", + manifest_path.display() + )); + } + }; + let original = manifest.clone(); ensure_manifest_seed_tasks(root, &mut manifest); - write_manifest(&manifest_path, &manifest)?; - Ok(manifest) + if canonical_manifest_installed && manifest == original { + return Ok(manifest); + } + mutate_manifest_at(root, |current| { + ensure_manifest_seed_tasks(root, current); + Ok(current.clone()) + }) } pub(crate) fn read_manifest_for_project_with_godot_root_calibration( @@ -673,17 +660,85 @@ pub(crate) fn ensure_initial_game_iteration_version_at( } } +/// Appends the formal version produced by a successful direct Agent turn. +/// +/// The project revision is the cross-surface ordering boundary: the launcher +/// rejects a same-revision manifest whose assets or versions differ from the +/// snapshot it already rendered. Therefore callers must advance the durable +/// project revision first and pass that new value here. +pub(crate) fn append_agent_game_iteration_version_at( + root: &Path, + project_revision: u64, +) -> Result { + if project_revision == 0 { + return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string()); + } + mutate_manifest_at(root, |manifest| { + let resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }) + .collect::>(); + if let Some(existing) = manifest + .versions + .iter() + .find(|version| version.project_revision == project_revision) + { + if existing.resource_bindings == resource_bindings { + return Ok(false); + } + return Err(format!( + "项目 revision {project_revision} 已绑定不同的正式版本" + )); + } + + let (version_id, parent_version_id, created_reason) = + if let Some(parent) = manifest.versions.last() { + if project_revision <= parent.project_revision { + return Err(format!( + "Agent 项目版本 revision {project_revision} 必须大于父版本 revision {}", + parent.project_revision + )); + } + ( + format!("agent-{project_revision}"), + Some(parent.version_id.clone()), + GameIterationVersionCreatedReason::AgentRevision, + ) + } else { + ( + format!("initial-{project_revision}"), + None, + GameIterationVersionCreatedReason::Initial, + ) + }; + manifest.versions.push(GameIterationVersion { + version_id, + parent_version_id, + project_revision, + resource_bindings, + created_reason, + created_at: unix_timestamp(), + edit_prompt: None, + }); + Ok(true) + }) +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, ) -> Result { - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; - ensure_manifest_seed_tasks(root, &mut manifest); - if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) { - manifest.goal = Some(goal.to_string()); - } - write_manifest(&manifest_path, &manifest)?; - Ok(manifest) + mutate_manifest_at(root, |manifest| { + ensure_manifest_seed_tasks(root, manifest); + if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) { + manifest.goal = Some(goal.to_string()); + } + Ok(manifest.clone()) + }) } pub(crate) fn record_draft_task_progress( @@ -898,15 +953,14 @@ pub(crate) fn update_manifest_task_status_at( if task_id.is_empty() { return Err("任务 ID 不能为空".to_string()); } - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; - ensure_manifest_seed_tasks(root, &mut manifest); - let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else { - return Err(format!("项目任务不存在:{task_id}")); - }; - task.status = status; - let updated = task.clone(); - write_manifest(&manifest_path, &manifest)?; - Ok(updated) + mutate_manifest_at(root, |manifest| { + ensure_manifest_seed_tasks(root, manifest); + let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else { + return Err(format!("项目任务不存在:{task_id}")); + }; + task.status = status; + Ok(task.clone()) + }) } pub(crate) fn create_manifest_task_at( @@ -1076,6 +1130,28 @@ pub(crate) fn read_or_create_manifest( Ok((manifest_path, manifest)) } +pub(crate) fn mutate_manifest_at( + root: &Path, + mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + let manifest_path = root.join(".agent/manifest.json"); + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; + } + let _write_lock = acquire_manifest_write_lock(&manifest_path)?; + let (_, mut manifest) = read_or_create_manifest(root)?; + let result = mutate(&mut manifest)?; + write_manifest_locked(&manifest_path, &manifest)?; + Ok(result) +} + fn manifest_backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( ".{}.previous", @@ -1267,16 +1343,20 @@ where { validate_manifest_godot_project_root(manifest.godot_project_root.as_deref()) .map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?; - validate_game_iteration_versions(&manifest.versions) - .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; - let payload = serde_json::to_string_pretty(manifest) - .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } let _write_lock = acquire_manifest_write_lock(path)?; after_lock(); + write_manifest_locked(path, manifest) +} + +fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Result<(), String> { + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; + let payload = serde_json::to_string_pretty(manifest) + .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; if existing.versions.len() > manifest.versions.len() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 0e6bdd66e..e72d29970 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -43,6 +43,116 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +#[test] +fn manifest_read_installs_a_canonical_file_when_storage_is_missing() { + let root = unique_manifest_test_root("install-missing-canonical"); + let manifest_path = root.join(".agent/manifest.json"); + let backup_path = root.join(".agent/.manifest.json.previous"); + + let manifest = read_manifest_for_project(&root).expect("create missing manifest"); + assert_eq!(manifest.project_id, "local-project-draft"); + assert!(manifest_path.is_file()); + assert!(!backup_path.exists()); + assert_eq!( + read_manifest(&manifest_path).expect("read installed manifest"), + manifest + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn manifest_task_and_asset_mutations_are_serialized_across_the_full_transaction() { + let root = unique_manifest_test_root("serialized-task-asset-mutations"); + ensure_manifest_has_seed_tasks(&root, None).expect("seed manifest tasks"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write(root.join("assets/concurrent.png"), b"asset").expect("write asset fixture"); + + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let first_root = root.clone(); + let first = std::thread::spawn(move || { + mutate_manifest_at(&first_root, |manifest| { + let task = manifest + .tasks + .iter_mut() + .find(|task| task.id == "design-director") + .expect("design-director task"); + task.status = GameCreationAppTaskStatus::Completed; + locked_tx.send(()).expect("announce held manifest lock"); + release_rx.recv().expect("release held manifest lock"); + Ok(()) + }) + .expect("commit task status transaction"); + }); + + locked_rx.recv().expect("first transaction acquired lock"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let second_root = root.clone(); + let second = std::thread::spawn(move || { + started_tx + .send(()) + .expect("announce asset transaction start"); + crate::assets::register_local_asset_entry( + &second_root, + "assets/concurrent.png", + "ui-prototype", + "image/png", + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register concurrent asset"); + }); + started_rx.recv().expect("asset transaction started"); + release_tx.send(()).expect("release task transaction"); + first.join().expect("join task transaction"); + second.join().expect("join asset transaction"); + + let manifest = read_existing_manifest_for_project(&root).expect("read final manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + assert!(manifest + .assets + .iter() + .any(|asset| asset.local_path == "assets/concurrent.png")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canonical_manifest_read_does_not_reenter_the_write_lock() { + let root = unique_manifest_test_root("canonical-read-with-write-lock-held"); + ensure_manifest_has_seed_tasks(&root, None).expect("seed manifest tasks"); + let manifest_path = root.join(".agent/manifest.json"); + let _write_lock = acquire_manifest_write_lock(&manifest_path).expect("hold manifest lock"); + + let manifest = read_manifest_for_project(&root).expect("read canonical manifest"); + assert_eq!( + manifest.tasks.len(), + new_game_creation_app_seed_tasks().len() + ); + + drop(_write_lock); + fs::remove_dir_all(root).ok(); +} + fn version_fixture( version_id: &str, parent_version_id: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index f054e8ee9..91972015a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -327,6 +327,10 @@ struct ResourceEditLedger { #[serde(default)] api_identity_fingerprint: Option, #[serde(default)] + platform_owner_user_id: Option, + #[serde(default)] + platform_owner_api_base_url: Option, + #[serde(default)] service_identity_confirmation: Option, phase: ResourceEditLedgerPhase, endpoint: Option, @@ -735,6 +739,10 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", hasher.finalize()) } +fn is_registered_editor_reference_id(value: &str) -> bool { + value.starts_with("editor-resource-") || value.starts_with("editor-asset-") +} + fn resource_edit_state_sha256(value: &T, label: &str) -> Result { serde_json::to_vec(value) .map(|bytes| sha256_hex(&bytes)) @@ -831,6 +839,46 @@ fn update_resource_edit_phase( write_resource_edit_ledger(root, ledger) } +fn bind_resource_edit_platform_owner( + root: &Path, + ledger: &mut ResourceEditLedger, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + let Some(session) = session else { + return Ok(()); + }; + match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (None, None) + if ledger.remote_operation_id.is_none() && ledger.remote_object_key.is_none() => + { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + write_resource_edit_ledger(root, ledger) + } + (Some(user_id), Some(api_base_url)) + if user_id == session.user_id && api_base_url == session.api_base_url => + { + Ok(()) + } + _ => Err( + "authentication-required: 此资源编辑属于其他陶泥儿账号,当前账号不能恢复或提交" + .to_string(), + ), + } +} + +fn ensure_frozen_resource_edit_platform_session( + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + if let Some(session) = session { + validate_platform_session_snapshot(session)?; + } + Ok(()) +} + fn read_stable_resource_edit_file( root: &Path, relative_path: &str, @@ -1379,7 +1427,8 @@ async fn request_resource_edit_upload_ticket( .unwrap_or_else(|| "source.bin".to_string()); let response = client .post(format!( - "{api_base_url}/api/external/v1/assets/direct-upload-tickets" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets") )) .bearer_auth(api_key) .json(&resource_edit_upload_ticket_payload( @@ -1391,10 +1440,11 @@ async fn request_resource_edit_upload_ticket( .send() .await .map_err(|_| "创建源资源上传凭证失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if !response.status().is_success() { return Err(format!( @@ -1523,7 +1573,8 @@ async fn confirm_resource_edit_source( .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; let response = client .post(format!( - "{api_base_url}/api/external/v1/assets/objects/confirm" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm") )) .bearer_auth(api_key) .json(&serde_json::json!({ @@ -1538,10 +1589,11 @@ async fn confirm_resource_edit_source( .send() .await .map_err(|_| "确认源资源上传失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if !response.status().is_success() { return Err(format!( @@ -1564,6 +1616,92 @@ async fn confirm_resource_edit_source( .ok_or_else(|| "源资源确认响应缺少 assetObjectId".to_string()) } +async fn resolve_resource_edit_source_project( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + source: &ResourceEditSourceSnapshot, +) -> Result { + if let Some(project_id) = source + .source_asset + .as_ref() + .and_then(|asset| asset.source.canvas_project_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(project_id.to_string()); + } + prepare_external_canvas_generation_context(root, client, api_base_url, api_key) + .await + .map(|context| context.project_id) +} + +async fn register_resource_edit_source_image( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + ticket: &ResourceEditUploadTicket, + asset_object_id: &str, +) -> Result { + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = + image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let project_id = + resolve_resource_edit_source_project(root, client, api_base_url, api_key, source).await?; + let endpoint = format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(&project_id) + ); + let response = client + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(&endpoint) + )) + .bearer_auth(api_key) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), + "objectKey": ticket.object_key, + "assetObjectId": asset_object_id, + "width": decoded.width(), + "height": decoded.height(), + "sourceType": "uploaded", + "assetKind": source.asset_kind, + "generationInputs": { + "source": RESOURCE_EDIT_QUEUE_SOURCE, + "operationId": input.operation_id, + }, + })) + .send() + .await + .map_err(|_| "登记源图片项目资源失败".to_string())?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); + } + if !response.status().is_success() { + return Err(format!( + "登记源图片项目资源失败:HTTP {}", + response.status().as_u16() + )); + } + let payload = response + .json::() + .await + .map_err(|_| "解析源图片项目资源响应失败".to_string())?; + let data = external_editor_response_data(&payload); + json_string_field(data.get("resource").unwrap_or(data), "resourceId") + .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string()) +} + async fn ensure_resource_edit_source_reference( root: &Path, client: &reqwest::Client, @@ -1574,27 +1712,39 @@ async fn ensure_resource_edit_source_reference( ledger: &mut ResourceEditLedger, ) -> Result { if let Some(reference) = ledger.source_stable_reference.clone() { - return Ok(reference); - } - if let Some(asset) = source.source_asset.as_ref() { - if let Some(reference) = committed_resource_edit_object_key_for_asset(root, &asset.id)? { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; + if input.edit_kind != LocalProjectResourceEditKind::ImageReference + || is_registered_editor_reference_id(&reference) + { return Ok(reference); } - if let Some(reference) = asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) - .or_else(|| asset.source.asset_object_id.clone()) + // Legacy prepared ledgers could store an objectKey here. An image edit + // accepts only a registered resource or asset, so discard that transient + // preparation before any remote operation is accepted and register again. + ledger.source_stable_reference = None; + write_resource_edit_ledger(root, ledger)?; + } + if let Some(asset) = source.source_asset.as_ref() { + if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + if let Some(reference) = asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| { + !value.is_empty() + && !value.starts_with("local-asset:") + && !value.starts_with("draft-media:") + && !value.starts_with("task:") + }) + .map(str::to_string) + .filter(|_| asset.source.canvas_project_id.is_some()) + { + ledger.source_stable_reference = Some(reference.clone()); + write_resource_edit_ledger(root, ledger)?; + return Ok(reference); + } + } else if let Some(reference) = + committed_resource_edit_object_key_for_asset(root, &asset.id)? { ledger.source_stable_reference = Some(reference.clone()); write_resource_edit_ledger(root, ledger)?; @@ -1604,11 +1754,26 @@ async fn ensure_resource_edit_source_reference( let ticket = request_resource_edit_upload_ticket(client, api_base_url, api_key, input, source).await?; upload_resource_edit_source(&ticket, source, api_base_url).await?; - let _asset_object_id = + let asset_object_id = confirm_resource_edit_source(client, api_base_url, api_key, &ticket, source).await?; - ledger.source_stable_reference = Some(ticket.object_key.clone()); + let reference = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + register_resource_edit_source_image( + root, + client, + api_base_url, + api_key, + input, + source, + &ticket, + &asset_object_id, + ) + .await? + } else { + ticket.object_key.clone() + }; + ledger.source_stable_reference = Some(reference.clone()); write_resource_edit_ledger(root, ledger)?; - Ok(ticket.object_key) + Ok(reference) } fn take_resource_edit_chars(value: &str, max_chars: usize) -> String { @@ -1670,8 +1835,7 @@ fn resource_edit_remote_request( "/api/external/v1/editor/images/edits", serde_json::json!({ "prompt": prompt, - "sourceImageSrc": source_reference.ok_or_else(|| "图片编辑缺少稳定源引用".to_string())?, - "assetKind": source.asset_kind, + "sourceReferenceId": source_reference.ok_or_else(|| "图片编辑缺少正式源资源 ID".to_string())?, "assetLabel": asset_name, "generationInputs": generation_inputs, }), @@ -1764,18 +1928,38 @@ async fn submit_resource_edit_remote( .request_body_json .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; + let mut body_value = serde_json::from_str::(body) + .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + if editor_api_mode() == EditorApiMode::PlatformAccount { + if let Some(inputs) = body_value.as_object_mut().and_then(|body| { + body.entry("generationInputs") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + }) { + inputs.insert( + "source".to_string(), + serde_json::Value::String("ai-game-creator-client".to_string()), + ); + } + } let response = client - .post(format!("{api_base_url}{endpoint}")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(endpoint) + )) .bearer_auth(api_key) .header("Idempotency-Key", &ledger.idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.to_string()) + .body(body_value.to_string()) .send() .await .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if status == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -1783,18 +1967,15 @@ async fn submit_resource_edit_remote( update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; return Err("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string()); } - if status != reqwest::StatusCode::ACCEPTED { + if !matches!( + status, + reqwest::StatusCode::OK | reqwest::StatusCode::ACCEPTED + ) { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - if status.is_success() { - return Err(format!( - "result-unknown: External v1 资源编辑必须返回 HTTP 202,实际为 HTTP {}", - status.as_u16() - )); - } return Err(format!( "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", status.as_u16() @@ -1833,9 +2014,10 @@ async fn wait_for_resource_edit_remote( .remote_operation_id .as_deref() .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; - let operation_id = - url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); - let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id}"); + let status_url = format!( + "{api_base_url}{}", + resolve_platform_generation_status_route(&operation_id) + ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = 1_000; loop { @@ -1849,12 +2031,11 @@ async fn wait_for_resource_edit_remote( .send() .await .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err( - "authentication-required: External Editor API Key 无效或权限不足".to_string(), - ); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if [429, 502, 503, 504].contains(&response.status().as_u16()) { poll_after_ms = 2_000; @@ -1870,11 +2051,7 @@ async fn wait_for_resource_edit_remote( .json::() .await .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; - let data = external_editor_response_data(&payload); - let job = data - .get("job") - .filter(|value| value.is_object()) - .unwrap_or(data); + let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { let result = job @@ -2140,11 +2317,18 @@ async fn prepare_remote_resource_edit( prompt: &str, asset_name: &str, ledger: &mut ResourceEditLedger, + frozen_platform_session: Option<&PlatformSessionSnapshot>, ) -> Result<(), String> { - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + if platform_session.as_ref() != frozen_platform_session { + return Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ); + } + bind_resource_edit_platform_owner(root, ledger, frozen_platform_session)?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; match prepare_resource_edit_service_identity(root, ledger, &api_base_url, &api_key)? { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { @@ -2171,7 +2355,11 @@ async fn prepare_remote_resource_edit( .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? } else { let generated = if ledger.remote_operation_id.is_some() { - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? + let result = + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) + .await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + result } else { let source_reference = if matches!( input.edit_kind, @@ -2192,6 +2380,23 @@ async fn prepare_remote_resource_edit( } else { None }; + if input.edit_kind == LocalProjectResourceEditKind::ImageReference + && ledger.remote_operation_id.is_none() + && ledger.request_body_json.as_deref().is_some_and(|body| { + serde_json::from_str::(body) + .ok() + .is_some_and(|value| { + value.get("sourceImageSrc").is_some() + || value.get("sourceReferenceId").is_none() + }) + }) + { + // Only a not-yet-submitted legacy request may be rebuilt. Once an + // operation exists, preserve its immutable request and reconcile it. + ledger.endpoint = None; + ledger.request_body_json = None; + write_resource_edit_ledger(root, ledger)?; + } if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { let (endpoint, body) = resource_edit_remote_request( input, @@ -2209,12 +2414,17 @@ async fn prepare_remote_resource_edit( } let submission = submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() })?; ledger.remote_operation_id = Some(operation_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? + let result = + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) + .await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + result }; let (resource_id, object_key, asset_object_id, model) = extract_resource_edit_remote_identity(&generated)?; @@ -2236,6 +2446,7 @@ async fn prepare_remote_resource_edit( .await .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; + ensure_frozen_resource_edit_platform_session(platform_session.as_ref())?; let (media_type, extension) = validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; @@ -3598,10 +3809,8 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at( return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string()); } ensure_resource_edit_phase_resumable(&ledger.phase)?; - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; match prepare_resource_edit_service_identity(root, &mut ledger, &api_base_url, &api_key)? { ResourceEditServiceIdentityDecision::ConfirmationRequired(confirmation) => Ok(confirmation), ResourceEditServiceIdentityDecision::Ready => { @@ -3632,10 +3841,8 @@ pub(crate) async fn confirm_resource_edit_service_identity_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; let service_fingerprint = platform_art_generation_external_service_fingerprint(&api_base_url); let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; let mut ledger = read_resource_edit_ledger(root, &input.operation_id)? @@ -3887,6 +4094,8 @@ pub(crate) async fn derive_local_project_resource_at( provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, phase: ResourceEditLedgerPhase::Prepared, endpoint: None, @@ -3929,6 +4138,12 @@ pub(crate) async fn derive_local_project_resource_at( let _project_guard = project_lock.lock().await; return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); } + let frozen_platform_session = if input.edit_kind.is_remote_media() { + current_platform_session() + } else { + None + }; + bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; @@ -3957,8 +4172,16 @@ pub(crate) async fn derive_local_project_resource_at( })(), } } else if input.edit_kind.is_remote_media() { - prepare_remote_resource_edit(root, &input, &source, &prompt, &asset_name, &mut ledger) - .await + prepare_remote_resource_edit( + root, + &input, + &source, + &prompt, + &asset_name, + &mut ledger, + frozen_platform_session.as_ref(), + ) + .await } else { Err("当前资源类型没有编辑实现".to_string()) }; @@ -3978,18 +4201,64 @@ pub(crate) async fn derive_local_project_resource_at( } let project_lock = resource_edit_project_mutation_lock(root)?; let _project_guard = project_lock.lock().await; + ensure_frozen_resource_edit_platform_session(frozen_platform_session.as_ref())?; commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger) } #[cfg(test)] mod tests { use super::*; + use std::future::Future; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; const PROJECT_ID: &str = "resource-editor-test-project"; + async fn with_test_external_editor_credentials( + api_base_url: &str, + api_key: &str, + operation: impl Future, + ) -> T { + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + api_base_url.to_string(), + api_key.to_string(), + ), + operation, + ) + .await + } + + fn accept_resource_editor_fixture_connection( + listener: &TcpListener, + fixture: &str, + request_index: usize, + ) -> TcpStream { + listener + .set_nonblocking(true) + .expect("set resource editor fixture listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore resource editor fixture stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {fixture} request {request_index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept {fixture} request {request_index}: {error}"), + } + } + } + fn read_http_request(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(2))) @@ -4108,6 +4377,8 @@ mod tests { provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, phase, endpoint: None, @@ -4467,16 +4738,24 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth fixture"); let base_url = format!("http://{}", listener.local_addr().expect("auth address")); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept auth request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "auth fixture", 0); let _ = read_http_request(&mut stream); write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { submit_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("authentication status must fail"); + }) + .await + .expect_err("authentication status must fail"); server.join().expect("join auth fixture"); - assert!(error.contains("authentication-required")); + let expected_code = if status.starts_with("401") { + "authentication-required" + } else { + "permission-denied" + }; + assert!(error.contains(expected_code), "{status}: {error}"); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Prepared, "{status}"); assert!(ledger.remote_operation_id.is_none(), "{status}"); } @@ -4491,17 +4770,25 @@ mod tests { listener.local_addr().expect("poll auth address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept poll auth request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "poll auth fixture", 0); let request = read_http_request(&mut stream); assert!(request.starts_with("GET /api/external/v1/generations/remote-one ")); write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("poll authentication status must fail"); + }) + .await + .expect_err("poll authentication status must fail"); server.join().expect("join poll auth fixture"); - assert!(error.contains("authentication-required")); + let expected_code = if status.starts_with("401") { + "authentication-required" + } else { + "permission-denied" + }; + assert!(error.contains(expected_code), "{status}: {error}"); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted, "{status}"); assert_eq!(ledger.remote_operation_id.as_deref(), Some("remote-one")); } @@ -4512,7 +4799,8 @@ mod tests { listener.local_addr().expect("missing status address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept status request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "missing status fixture", 0); let _ = read_http_request(&mut stream); write_json( &mut stream, @@ -4520,10 +4808,12 @@ mod tests { serde_json::json!({"error": "missing"}), ); }); - let error = + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await - .expect_err("missing remote status must stay unknown"); + }) + .await + .expect_err("missing remote status must stay unknown"); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); @@ -4554,13 +4844,6 @@ mod tests { listener.local_addr().expect("service identity address") ); let new_api_key = "rotated-resource-editor-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": new_api_key - }}) - .to_string(), - ); let request = input( root, Uuid::new_v4().to_string(), @@ -4590,13 +4873,16 @@ mod tests { ledger.remote_operation_id = Some("legacy-remote-operation".to_string()); write_resource_edit_ledger(root, &ledger).expect("write legacy ledger"); - let first = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let first = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("issue service identity challenge"); assert_eq!( @@ -4610,7 +4896,7 @@ mod tests { .expect("challenged ledger"); changed.request_body_json = Some("{\"prompt\":\"changed\"}".to_string()); write_resource_edit_ledger(root, &changed).expect("change challenged snapshot"); - let stale_error = + let stale_error = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4619,16 +4905,21 @@ mod tests { challenge: first.challenge, }) .await - .expect_err("changed ledger must invalidate challenge"); + }) + .await + .expect_err("changed ledger must invalidate challenge"); assert!(stale_error.contains("上下文已变化"), "{stale_error}"); - let second = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let second = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("rotate stale challenge"); let mut expired = read_resource_edit_ledger(root, &request.operation_id) @@ -4640,7 +4931,7 @@ mod tests { .expect("second private challenge") .expires_at = unix_timestamp(); write_resource_edit_ledger(root, &expired).expect("expire service identity challenge"); - let expired_error = + let expired_error = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4649,18 +4940,23 @@ mod tests { challenge: second.challenge, }) .await - .expect_err("expired challenge must fail closed"); + }) + .await + .expect_err("expired challenge must fail closed"); assert!(expired_error.contains("已过期"), "{expired_error}"); - let current = request_resource_edit_service_identity_confirmation_at( - RequestResourceEditServiceIdentityConfirmationInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - operation_id: request.operation_id.clone(), - }, - ) + let current = with_test_external_editor_credentials(&base_url, new_api_key, async { + request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + }) .await .expect("rotate expired challenge"); - let confirmed = + let confirmed = with_test_external_editor_credentials(&base_url, new_api_key, async { confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -4669,14 +4965,17 @@ mod tests { challenge: current.challenge, }) .await - .expect("confirm current service identity"); + }) + .await + .expect("confirm current service identity"); assert_eq!( confirmed.identity_scheme, PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept resumed poll"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "resumed poll fixture", 0); let request = read_http_request(&mut stream); assert!( request.starts_with("GET /api/external/v1/generations/legacy-remote-operation ") @@ -4697,13 +4996,16 @@ mod tests { let mut resumed = read_resource_edit_ledger(root, &request.operation_id) .expect("read confirmed ledger") .expect("confirmed ledger"); - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - new_api_key, - &mut resumed, - ) + with_test_external_editor_credentials(&base_url, new_api_key, async { + wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + new_api_key, + &mut resumed, + ) + .await + }) .await .expect("resume accepted operation by GET"); server.join().expect("join resumed poll server"); @@ -4757,7 +5059,11 @@ mod tests { listener.local_addr().expect("submission address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept submission request"); + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "submission status fixture", + 0, + ); let request = read_http_request(&mut stream); assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); write_json( @@ -4766,13 +5072,16 @@ mod tests { serde_json::json!({"error": "provider detail must not persist"}), ); }); - let error = submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("submission status must fail"); server.join().expect("join submission fixture"); @@ -4873,7 +5182,11 @@ mod tests { listener.local_addr().expect("accepted response address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept submission request"); + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "accepted response fixture", + 0, + ); let request = read_http_request(&mut stream); assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); write_bytes( @@ -4883,13 +5196,16 @@ mod tests { response_body.as_bytes(), ); }); - let error = submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("unproven accepted response must fail closed"); server.join().expect("join accepted fixture"); @@ -4945,13 +5261,6 @@ mod tests { .expect("unavailable endpoint address") ); drop(unavailable); - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": { - "baseUrl": base_url, - "apiKey": "transport-failure-key" - }}) - .to_string(), - ); let mut request = input( root, Uuid::new_v4().to_string(), @@ -4963,7 +5272,10 @@ mod tests { request.source_subtype = Some("background-music".to_string()); request.producer_task_id = Some(task_id); - let error = derive_local_project_resource_at(request.clone()) + let error = + with_test_external_editor_credentials(&base_url, "transport-failure-key", async { + derive_local_project_resource_at(request.clone()).await + }) .await .expect_err("transport failure must fail closed"); assert!(error.contains("result-unknown"), "{error}"); @@ -5021,7 +5333,8 @@ mod tests { listener.local_addr().expect("failed status address") ); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept failed status request"); + let mut stream = + accept_resource_editor_fixture_connection(&listener, "failed status fixture", 0); let request = read_http_request(&mut stream); assert!(request.starts_with("GET /api/external/v1/generations/remote-terminal-failure")); write_json( @@ -5031,13 +5344,16 @@ mod tests { ); }); - let error = wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) + let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { + wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + }) .await .expect_err("failed status must become terminal"); server.join().expect("join failed status fixture"); @@ -5210,7 +5526,7 @@ mod tests { let mut image = bgm; image.edit_kind = LocalProjectResourceEditKind::ImageReference; - let (image_endpoint, _) = resource_edit_remote_request( + let (image_endpoint, image_body) = resource_edit_remote_request( &image, &source, prompt, @@ -5219,6 +5535,11 @@ mod tests { ) .expect("build image request"); assert_eq!(image_endpoint, "/api/external/v1/editor/images/edits"); + assert_eq!( + image_body["sourceReferenceId"], + serde_json::json!("stable-image-reference") + ); + assert!(image_body.get("sourceImageSrc").is_none()); assert!(!is_external_resource_edit_endpoint( "/api/editor/videos/generations" )); @@ -5258,8 +5579,12 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..7 { - let (mut stream, _) = listener.accept().expect("accept External video request"); + for request_index in 0..7 { + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "External video fixture", + request_index, + ); let request = read_http_request(&mut stream); sender .send(request.clone()) @@ -5353,9 +5678,6 @@ mod tests { } }); let api_key = "resource-editor-external-key"; - let _config_guard = crate::tests::write_test_local_config( - serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), - ); let mut request = input( root, Uuid::new_v4().to_string(), @@ -5367,9 +5689,11 @@ mod tests { request.source_media_type = Some(source_asset.media_type.clone()); request.asset_name = "源视频编辑版".to_string(); - let result = derive_local_project_resource_at(request.clone()) - .await - .expect("derive External video"); + let result = with_test_external_editor_credentials(&base_url, api_key, async { + derive_local_project_resource_at(request.clone()).await + }) + .await + .expect("derive External video"); server.join().expect("join External video server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 7); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 202a774b8..fac9699bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -9,9 +9,10 @@ mod state; #[allow(unused_imports)] pub(crate) use client::{ attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal, - compact_external_agent_runner_context, configure_external_agent_runner, - configure_external_agent_runner_read_only, continue_external_agent_runner_action, - ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui, + clear_external_agent_runner_platform_session, compact_external_agent_runner_context, + configure_external_agent_runner, configure_external_agent_runner_read_only, + continue_external_agent_runner_action, ensure_external_agent_runner_started, + ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, @@ -33,5 +34,39 @@ pub(crate) use server::{ bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, }; +#[cfg(test)] +pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( + root: &std::path::Path, + config_dir: &std::path::Path, + previous_boot_id: &str, + current_boot_id: &str, +) -> Result<(std::path::PathBuf, Option), String> { + let previous = project_owner::acquire_external_agent_runner_project_execution_owner( + root, + previous_boot_id, + protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + )?; + drop(previous); + let state = state::ExternalAgentRunnerServerState::new( + config_dir.join(protocol::EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + protocol::ExternalAgentRunnerEndpoint { + protocol_version: protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + pid: std::process::id(), + boot_id: current_boot_id.to_string(), + port: 41009, + token: "owner-provider-token-owner-provider-token".to_string(), + heartbeat_at: 1_725_000_000_000, + executable_fingerprint: Some("a".repeat(64)), + process_start_identity: None, + }, + ); + let claim = state.claim_project_execution_owner(root)?; + let result = (claim.root, claim.recovered_from_boot_id); + let second = state.claim_project_execution_owner(root)?; + if second.recovered_from_boot_id.is_some() { + return Err("同 boot execution owner 重入重复触发恢复".to_string()); + } + Ok(result) +} #[cfg(test)] mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 4f626d26b..405a58e64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -106,7 +106,7 @@ fn redact_url_queries(line: &str) -> String { .join(" ") } -fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { +pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { let lowercase = line.to_ascii_lowercase(); if [ "authorization", @@ -123,6 +123,9 @@ fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { "set-cookie", "secret", "access_token", + "accesstoken", + "platform_access_token", + "platformaccesstoken", "refresh_token", "\"token\"", "'token'", @@ -993,18 +996,125 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; + let platform_session = crate::current_platform_session(); register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), + platform_user_id: platform_session + .as_ref() + .map(|session| session.user_id.clone()), + platform_access_token: platform_session + .as_ref() + .map(|session| session.access_token.clone()), + platform_api_base_url: platform_session + .as_ref() + .map(|session| session.api_base_url.clone()), + platform_auth_generation: platform_session.map(|session| session.generation), ..ExternalAgentRunnerRequestParams::default() }, ); ensure_external_agent_runner(&config_dir).map(|_| ()) } +pub(crate) fn install_external_agent_runner_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + Some((user_id, access_token, api_base_url)), + generation, + ); + let config_dir = external_agent_runner_config_dir() + .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; + let endpoint = { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + ensure_external_agent_runner(&config_dir)? + }; + send_external_agent_runner_request( + &endpoint, + "platform.session.install", + ExternalAgentRunnerRequestParams { + platform_user_id: Some(user_id.to_string()), + platform_access_token: Some(access_token.to_string()), + platform_api_base_url: Some(api_base_url.to_string()), + platform_auth_generation: Some(generation), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .map(|_| ()) +} + +pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + None, + generation, + ); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else { + return Ok(()); + }; + send_external_agent_runner_request( + &endpoint, + "platform.session.clear", + ExternalAgentRunnerRequestParams { + platform_auth_generation: Some(generation), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .map(|_| ()) +} + +pub(super) fn remember_external_agent_runner_platform_session( + state: &Mutex, + session: Option<(&str, &str, &str)>, + generation: u64, +) { + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return; + }; + let current_generation = registration.params.platform_auth_generation.unwrap_or(0); + if generation < current_generation { + return; + } + if generation == current_generation { + match session { + Some((user_id, access_token, api_base_url)) + if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_access_token.as_deref() + == Some(access_token) + && registration.params.platform_api_base_url.as_deref() + == Some(api_base_url) => + { + return; + } + Some(_) => return, + None if registration.params.platform_user_id.is_none() + && registration.params.platform_access_token.is_none() => + { + return; + } + None => {} + } + } + registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); + registration.params.platform_access_token = + session.map(|(_, access_token, _)| access_token.to_string()); + registration.params.platform_api_base_url = + session.map(|(_, _, api_base_url)| api_base_url.to_string()); + registration.params.platform_auth_generation = Some(generation); +} + pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( result: &Value, ) -> Result<(), String> { @@ -1295,6 +1405,10 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( steer_id: steer_id.map(str::to_string), event_sink_port: None, event_sink_token: None, + platform_user_id: None, + platform_access_token: None, + platform_api_base_url: None, + platform_auth_generation: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 7657aeb2b..cbee001a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -368,10 +368,12 @@ pub(super) fn dispatch_external_agent_runner_wake_pending_request( pub(super) fn external_agent_runner_response_is_cacheable( response: &ExternalAgentRunnerResponse, ) -> bool { - response - .error - .as_ref() - .is_none_or(|error| error.code != EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE) + response.error.as_ref().is_none_or(|error| { + !matches!( + error.code.as_str(), + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE | "project-execution-owned" + ) + }) } pub(super) fn cache_external_agent_runner_response_if_cacheable( @@ -392,8 +394,77 @@ pub(super) fn cache_external_agent_runner_response_if_cacheable( pub(super) fn dispatch_external_agent_runner_runtime_request( request: &ExternalAgentRunnerRequest, state: &ExternalAgentRunnerServerState, +) -> ExternalAgentRunnerResponse { + dispatch_external_agent_runner_runtime_request_with_owner_claim(request, state, |root| { + state.claim_project_execution_owner(root) + }) +} + +pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( + request: &ExternalAgentRunnerRequest, + state: &ExternalAgentRunnerServerState, + claim_owner: impl Fn(&Path) -> Result, ) -> ExternalAgentRunnerResponse { let fingerprint = external_agent_runner_request_fingerprint(request); + { + let cache = lock_unpoisoned(&state.write_request_cache); + if let Some(cached) = cache.find(&request.request_id) { + if cached.fingerprint == fingerprint { + return cached.response.clone(); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "request-id-conflict", + "同一 requestId 不能用于不同请求", + ); + } + } + + let requires_project_execution_owner = matches!( + request.method.as_str(), + "runtime.wake_pending" + | "runtime.resume" + | "runtime.continue_action" + | "runtime.steer" + | "runtime.interrupt_for_steer_decision" + | "runtime.pause" + | "runtime.cancel" + | "runtime.compact" + ); + if requires_project_execution_owner && state.draining.load(Ordering::Acquire) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 正在排空并准备退出,拒绝新的写请求", + ); + } + + let token = state.endpoint_snapshot().token; + let claimed_project_root = if requires_project_execution_owner { + let root = match external_agent_runner_request_root(request) { + Ok(root) => root, + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "invalid-params", + error, + ); + } + }; + match claim_owner(&root) { + Ok(claim) => Some(claim.root), + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "project-execution-owned", + error, + ); + } + } + } else { + None + }; + let mut cache = lock_unpoisoned(&state.write_request_cache); if let Some(cached) = cache.find(&request.request_id) { if cached.fingerprint == fingerprint { @@ -406,26 +477,6 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ); } - if matches!( - request.method.as_str(), - "runtime.wake_pending" - | "runtime.resume" - | "runtime.continue_action" - | "runtime.steer" - | "runtime.interrupt_for_steer_decision" - | "runtime.pause" - | "runtime.cancel" - | "runtime.compact" - ) && state.draining.load(Ordering::Acquire) - { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "runner-draining", - "Agent Runner 正在排空并准备退出,拒绝新的写请求", - ); - } - - let token = state.endpoint_snapshot().token; let response = match request.method.as_str() { "runtime.wake_pending" | "runtime.resume" @@ -435,26 +486,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.pause" | "runtime.cancel" | "runtime.compact" => { - let root = match external_agent_runner_request_root(request) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "invalid-params", - error, - ); - } - }; - let root = match state.claim_project_execution_owner(&root) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "project-execution-owned", - error, - ); - } - }; + let root = claimed_project_root + .expect("runtime write request must claim its project execution owner"); if request.method == "runtime.wake_pending" { dispatch_external_agent_runner_wake_pending_request(request, &root, &token) } else { @@ -620,6 +653,30 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( redact_runner_secret(&error, &token), ); } + if let ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(generation), + ) = ( + request.params.platform_user_id.as_deref(), + request.params.platform_access_token.as_deref(), + request.params.platform_api_base_url.as_deref(), + request.params.platform_auth_generation, + ) { + if let Err(error) = crate::install_platform_session( + user_id, + access_token, + api_base_url, + generation, + ) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + error, + ); + } + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, @@ -638,6 +695,41 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ), } } + "platform.session.install" => { + let result = request + .params + .platform_user_id + .as_deref() + .zip(request.params.platform_access_token.as_deref()) + .zip(request.params.platform_api_base_url.as_deref()) + .zip(request.params.platform_auth_generation) + .ok_or_else(|| "平台登录态同步参数不完整".to_string()) + .and_then(|(((user_id, access_token), api_base_url), generation)| { + crate::install_platform_session(user_id, access_token, api_base_url, generation) + }); + match result { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "installed": true }), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + error, + ), + } + } + "platform.session.clear" => { + let Some(generation) = request.params.platform_auth_generation else { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + "平台登录态清除缺少 authGeneration", + ); + }; + crate::clear_platform_session(generation); + ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true })) + } "runner.shutdown" | "shutdown" => { let provider_requests_interrupted = request_external_agent_runner_forced_shutdown(state); @@ -865,6 +957,8 @@ pub(super) fn handle_external_agent_runner_request( | "runtime.cancel" | "runtime.compact" | "runner.attach_gui_owner" + | "platform.session.install" + | "platform.session.clear" | "runner.shutdown" | "shutdown" | "runner.shutdown_for_client_exit" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 451309200..579987f45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -268,6 +268,14 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) event_sink_port: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) event_sink_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_api_base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_generation: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index a59bc1af2..74b0a5402 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -1,9 +1,10 @@ use super::{endpoint::*, project_owner::*, protocol::*}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Condvar, Mutex, MutexGuard}; pub(super) struct ExternalAgentRunnerServerState { pub(super) endpoint_path: PathBuf, @@ -16,10 +17,66 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) known_roots: Mutex>, pub(super) gui_owner_lock_path: PathBuf, pub(super) project_execution_owners: - Mutex>, + Mutex>, + project_execution_owner_recovery_changed: Condvar, pub(super) write_request_cache: Mutex, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerClaim { + pub(super) root: PathBuf, + pub(super) recovered_from_boot_id: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus { + Pending, + Running, + Completed, +} + +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: ExternalAgentRunnerProjectExecutionOwner, + recovered_from_boot_id: Option, + recovery_status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus, +} + +struct ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'a> { + state: &'a ExternalAgentRunnerServerState, + root: PathBuf, + running: bool, +} + +impl ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn complete(mut self) { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed); + self.running = false; + } + + fn update_status(&self, status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus) { + let mut owners = lock_unpoisoned(&self.state.project_execution_owners); + if let Some(entry) = owners.get_mut(&self.root) { + if entry.recovery_status + == ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running + { + entry.recovery_status = status; + } + } + drop(owners); + self.state + .project_execution_owner_recovery_changed + .notify_all(); + } +} + +impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn drop(&mut self) { + if self.running { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending); + } + } +} + impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { let gui_owner_lock_path = endpoint_path @@ -37,6 +94,7 @@ impl ExternalAgentRunnerServerState { known_roots: Mutex::new(BTreeSet::new()), gui_owner_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), + project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), } } @@ -57,28 +115,103 @@ impl ExternalAgentRunnerServerState { lock_unpoisoned(&self.known_roots).iter().cloned().collect() } - pub(super) fn claim_project_execution_owner(&self, root: &Path) -> Result { + pub(super) fn claim_project_execution_owner( + &self, + root: &Path, + ) -> Result { + self.claim_project_execution_owner_with_recovery(root, |root| { + crate::resume_game_creator_agent_background_tasks_at(root).map(|_| ()) + }) + } + + pub(super) fn claim_project_execution_owner_with_recovery( + &self, + root: &Path, + recover: impl Fn(&Path) -> Result<(), String>, + ) -> Result { let root = canonicalize_external_agent_runner_project_root(root)?; let config_dir = self .endpoint_path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; crate::validate_game_creator_runtime_config_dir_outside_project(config_dir, &root)?; - - let mut owners = lock_unpoisoned(&self.project_execution_owners); - if owners.contains_key(&root) { - self.remember_root(&root); - return Ok(root); - } let endpoint = self.endpoint_snapshot(); - let owner = acquire_external_agent_runner_project_execution_owner( - &root, - &endpoint.boot_id, - endpoint.protocol_version, - )?; - owners.insert(root.clone(), owner); - self.remember_root(&root); - Ok(root) + + loop { + let mut owners = lock_unpoisoned(&self.project_execution_owners); + if !owners.contains_key(&root) { + let owner = acquire_external_agent_runner_project_execution_owner( + &root, + &endpoint.boot_id, + endpoint.protocol_version, + )?; + let recovered_from_boot_id = owner.recovered_from_boot_id().map(str::to_string); + let recovery_status = if recovered_from_boot_id.is_some() { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending + } else { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed + }; + owners.insert( + root.clone(), + ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: owner, + recovered_from_boot_id, + recovery_status, + }, + ); + } + + let entry = owners + .get_mut(&root) + .expect("project execution owner entry must exist after acquisition"); + match entry.recovery_status { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed => { + drop(owners); + self.remember_root(&root); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: None, + }); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running => { + owners = + wait_unpoisoned(&self.project_execution_owner_recovery_changed, owners); + drop(owners); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending => { + entry.recovery_status = + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running; + let recovered_from_boot_id = entry + .recovered_from_boot_id + .clone() + .expect("pending project owner recovery must name the previous boot"); + drop(owners); + self.remember_root(&root); + + let recovery_guard = ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard { + state: self, + root: root.clone(), + running: true, + }; + match catch_unwind(AssertUnwindSafe(|| recover(&root))) { + Ok(Ok(())) => { + recovery_guard.complete(); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: Some(recovered_from_boot_id), + }); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err( + "Agent Runner 项目恢复回调异常终止,可复用同一 requestId 重试" + .to_string(), + ) + } + } + } + } + } } } @@ -122,6 +255,12 @@ pub(super) struct ExternalAgentRunnerProjectExecutionOwner { pub(super) _record: ExternalAgentRunnerProjectExecutionOwnerRecord, } +impl ExternalAgentRunnerProjectExecutionOwner { + pub(super) fn recovered_from_boot_id(&self) -> Option<&str> { + self._record.recovered_from_boot_id.as_deref() + } +} + pub(super) struct ExternalAgentRunnerEndpointGuard { pub(super) path: PathBuf, pub(super) boot_id: String, @@ -160,3 +299,9 @@ pub(super) fn lock_unpoisoned(mutex: &Mutex) -> MutexGuard<'_, T> { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + +fn wait_unpoisoned<'a, T>(condvar: &Condvar, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> { + condvar + .wait(guard) + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index ae5c57438..d797419bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -9,10 +9,15 @@ use std::fs; use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Barrier, Mutex}; use std::time::{Duration, Instant}; +use crate::{ + default_agent_runtime_run_profile, unix_timestamp, AgentRuntimeTaskRecord, + ProcessSessionRecord, AGENT_RUNTIME_SCHEMA_VERSION, +}; + static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); struct TestDirectoryGuard(PathBuf); @@ -85,7 +90,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 6); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -639,6 +644,80 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { ); } +#[test] +fn gui_owner_registration_replays_only_the_latest_platform_session() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("platform-session-replay-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_330), + event_sink_token: Some("f".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + remember_external_agent_runner_platform_session( + &state, + Some(("user-a", "token-a", "https://dev.genarrative.world")), + 4, + ); + remember_external_agent_runner_platform_session(&state, None, 5); + remember_external_agent_runner_platform_session( + &state, + Some(("user-a", "late-token-a", "https://dev.genarrative.world")), + 4, + ); + remember_external_agent_runner_platform_session( + &state, + Some(( + "user-a", + "same-generation-token", + "https://dev.genarrative.world", + )), + 5, + ); + remember_external_agent_runner_platform_session( + &state, + Some(("user-b", "token-b", "https://dev.genarrative.world")), + 6, + ); + + let endpoint = test_endpoint( + "platform-session-replay-runner-token", + "platform-session-replay-boot", + 31_330, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, params| { + assert_eq!(params.platform_user_id.as_deref(), Some("user-b")); + assert_eq!(params.platform_access_token.as_deref(), Some("token-b")); + assert_eq!( + params.platform_api_base_url.as_deref(), + Some("https://dev.genarrative.world") + ); + assert_eq!(params.platform_auth_generation, Some(6)); + Ok(()) + }, + ) + .expect("latest platform session is replayed"); +} + +#[test] +fn runner_output_redacts_camel_case_platform_access_token_fields() { + let config_dir = Path::new("private-appdata"); + assert_eq!( + sanitize_agent_runner_output( + r#"request={"platformAccessToken":"must-not-leak"}"#, + config_dir, + ), + "" + ); +} + #[test] fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); @@ -2592,9 +2671,10 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { ), ); - state_a + let first_claim = state_a .claim_project_execution_owner(&root) .expect("first appdata owns project"); + assert!(first_claim.recovered_from_boot_id.is_none()); let conflict = state_b .claim_project_execution_owner(&root) .expect_err("second appdata must not own the same project"); @@ -2604,7 +2684,11 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { let mut recovered = false; for attempt in 0..100 { match state_b.claim_project_execution_owner(&root) { - Ok(_) => { + Ok(claim) => { + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-boot-a") + ); recovered = true; break; } @@ -2638,6 +2722,567 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { .expect("project owner diagnostic must match TokenUser SID"); } +#[test] +fn project_execution_owner_cross_boot_claim_recovers_durable_runtime_once() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-auto-recovery", + "Runner owner 自动恢复测试", + ) + .expect("initialize owner auto recovery project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner auto recovery appdata"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-auto-recovery-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-auto-recovery-token-owner-auto-recovery-token", + "owner-auto-recovery-boot-b", + 41003, + ), + ); + let task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "owner-auto-recovery-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "跨 boot 恢复 durable task".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "上一 boot 正在规划".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp().saturating_sub(60), + }; + let task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); + fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir"); + fs::write( + &task_path, + format!( + "{}\n", + serde_json::to_string(&task).expect("serialize durable task") + ), + ) + .expect("write durable task"); + let mut runtime = crate::agent_runtime_state_from_task_record(&task); + crate::write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write stale runtime state"); + let project_id = crate::game_creator_agent_runtime_context_project_id(&root) + .expect("read runtime project id"); + let process_id = "proc-0123456789abcdef0123456789abcdef"; + let now = unix_timestamp(); + let process_record = ProcessSessionRecord { + schema_version: "3".to_string(), + project_id, + agent_id: task.agent_id.clone(), + task_id: task.task_id.clone(), + conversation_session_id: task.session_id.clone(), + run_id: task.run_id.clone(), + start_action_id: "action-0123456789abcdef01234567".to_string(), + start_action_fingerprint: "a".repeat(64), + process_id: process_id.to_string(), + owner_boot_id: "owner-auto-recovery-boot-a".to_string(), + command_id: "command-owner-auto-recovery".to_string(), + program: "node".to_string(), + cwd: ".".to_string(), + sandbox_backend: "test-unknown".to_string(), + sandbox_mode: "unknown".to_string(), + network_access: "unknown".to_string(), + sandbox_profile_version: "test-v1".to_string(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + launch_failure_kind: None, + sandbox_ready_at: Some(now), + exec_established_at: Some(now), + status: "running".to_string(), + exit_code: None, + signal: None, + stdin_open: true, + output_bytes: 0, + output_sha256: format!("{:x}", Sha256::digest([])), + output_ref: None, + source_fingerprint_before: "b".repeat(64), + source_fingerprint_after: None, + source_changed: None, + needs_reconciliation: false, + started_at: now, + terminal_at: None, + updated_at: now, + }; + let process_path = root.join(format!(".agent/runtime/process-sessions/{process_id}.json")); + fs::create_dir_all(process_path.parent().expect("process session parent")) + .expect("process session directory"); + fs::write( + &process_path, + serde_json::to_vec_pretty(&process_record).expect("serialize process session"), + ) + .expect("write previous boot process session"); + + let first = state + .claim_project_execution_owner(&root) + .expect("cross boot claim automatically scans recovery"); + assert_eq!( + first.recovered_from_boot_id.as_deref(), + Some("owner-auto-recovery-boot-a") + ); + runtime = crate::read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconciled runtime") + .state; + assert_eq!(runtime.run_id, "owner-auto-recovery-run"); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("禁止按 PID 重连或重放启动动作"))); + let process = serde_json::from_slice::( + &fs::read(&process_path).expect("read reconciled process session"), + ) + .expect("parse reconciled process session"); + assert_eq!(process.status, "needs-reconciliation"); + assert!(process.needs_reconciliation); + let records = fs::read_to_string(root.join(".agent/agent.db")).expect("read recovery audit"); + assert_eq!( + records + .matches("agent.runtime.process_session.reconciled_after_runner_restart") + .count(), + 1 + ); + assert!(!records.contains("agent.runtime.tool_action.executing")); + assert!(!records.contains("agent.runtime.agent.delegate")); + let task_records_before = fs::read_to_string(&task_path).expect("read reconciled task journal"); + let task_record_count_before = task_records_before + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let conversation_directory = root.join(".agent/conversations"); + let conversation_snapshot_before = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("read conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("read conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + + let second = state + .claim_project_execution_owner(&root) + .expect("same boot claim is idempotent"); + assert!(second.recovered_from_boot_id.is_none()); + let records_after = + fs::read_to_string(root.join(".agent/agent.db")).expect("read idempotent recovery audit"); + assert_eq!(records_after, records); + let task_records_after = + fs::read_to_string(&task_path).expect("reread reconciled task journal"); + assert_eq!( + task_records_after + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + task_record_count_before, + "same-boot reentry must not duplicate durable task/child projection" + ); + let conversation_snapshot_after = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("reread conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("reread conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + assert_eq!(conversation_snapshot_after, conversation_snapshot_before); +} + +#[test] +fn project_execution_owner_recovery_runs_after_owner_map_lock_release() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-lock-order", + "Runner owner 恢复锁顺序测试", + ) + .expect("initialize owner lock order project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-lock-order-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner lock order appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-lock-order-token-owner-lock-order-token", + "owner-lock-order-boot-b", + 41004, + ), + ); + + let claim = state + .claim_project_execution_owner_with_recovery(&root, |claimed_root| { + let owners = state + .project_execution_owners + .try_lock() + .expect("recovery callback must run after owner-map lock release"); + assert!(owners.contains_key(claimed_root)); + Ok(()) + }) + .expect("claim with lock-order probe"); + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-lock-order-boot-a") + ); +} + +#[test] +fn project_execution_owner_recovery_failure_retries_in_same_boot() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-retry", + "Runner owner 恢复重试测试", + ) + .expect("initialize owner recovery retry project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-retry-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery retry appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-retry-token-owner-retry-token", + "owner-recovery-retry-boot-b", + 41005, + ), + ); + let recoveries = AtomicUsize::new(0); + + let first = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Err("injected recovery failure".to_string()) + }) + .expect_err("first recovery attempt must surface its failure"); + assert!(first.contains("injected recovery failure")); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); + + let second = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("same boot must retry failed recovery"); + assert_eq!( + second.recovered_from_boot_id.as_deref(), + Some("owner-recovery-retry-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); + + let third = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("completed recovery remains idempotent"); + assert!(third.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_concurrent_claims_single_flight_recovery() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-single-flight", + "Runner owner 单飞恢复测试", + ) + .expect("initialize owner recovery single-flight project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-single-flight-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery single-flight appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-single-flight-token-single-flight", + "owner-recovery-single-flight-boot-b", + 41006, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let recovery_entered = Arc::new(Barrier::new(2)); + let (release_recovery_tx, release_recovery_rx) = mpsc::channel::<()>(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first_recovery_entered = Arc::clone(&recovery_entered); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_recovery_entered.wait(); + release_recovery_rx + .recv_timeout(Duration::from_secs(5)) + .expect("test must release the active recovery"); + Ok(()) + }) + }); + recovery_entered.wait(); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal second claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("second claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "concurrent claim must wait instead of duplicating recovery" + ); + + release_recovery_tx + .send(()) + .expect("release active recovery"); + let first_claim = first + .join() + .expect("first claim thread") + .expect("first claim"); + let second_claim = second + .join() + .expect("second claim thread") + .expect("second claim"); + assert_eq!( + first_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-single-flight-boot-a") + ); + assert!(second_claim.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); +} + +#[test] +fn project_execution_owner_recovery_panic_resets_running_and_wakes_waiter() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-panic", + "Runner owner 恢复 panic 测试", + ) + .expect("initialize owner recovery panic project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-panic-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery panic appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-panic-token-owner-panic-token", + "owner-recovery-panic-boot-b", + 41007, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let (first_entered_tx, first_entered_rx) = mpsc::channel(); + let (release_first_tx, release_first_rx) = mpsc::channel(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_entered_tx.send(()).expect("signal panic recovery"); + release_first_rx + .recv_timeout(Duration::from_secs(5)) + .expect("release panic recovery"); + panic!("injected recovery callback panic"); + }) + }); + first_entered_rx + .recv_timeout(Duration::from_secs(5)) + .expect("first recovery must enter"); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal waiting claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("waiting claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "waiter must not run while the first recovery is Running" + ); + + release_first_tx + .send(()) + .expect("release the panicking recovery"); + let first_error = first + .join() + .expect("panic must be contained inside owner recovery") + .expect_err("panicking recovery must fail its own claim"); + assert!(first_error.contains("恢复回调异常终止")); + let second_claim = second + .join() + .expect("waiting claim thread must be woken") + .expect("waiting claim must retry recovery"); + assert_eq!( + second_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-panic-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_recovery_runs_without_request_cache_and_failure_is_retryable() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-request-cache", + "Runner owner 请求缓存锁测试", + ) + .expect("initialize owner request cache project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner request cache appdata"); + let token = "owner-request-cache-token-owner-request-cache-token"; + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "owner-request-cache-boot", 41008), + ); + let request = ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "owner-request-cache-request".to_string(), + token: token.to_string(), + method: "runtime.resume".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + let attempts = AtomicUsize::new(0); + + let first = + dispatch_external_agent_runner_runtime_request_with_owner_claim(&request, &state, |_| { + attempts.fetch_add(1, Ordering::SeqCst); + let cache = state + .write_request_cache + .try_lock() + .expect("owner recovery must not run under the global request-cache mutex"); + assert!(cache.find(&request.request_id).is_none()); + Err("injected owner recovery failure".to_string()) + }); + assert!(!first.ok); + assert_eq!( + first.error.as_ref().map(|error| error.code.as_str()), + Some("project-execution-owned") + ); + assert!(lock_unpoisoned(&state.write_request_cache) + .find(&request.request_id) + .is_none()); + + let second = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |requested_root| { + attempts.fetch_add(1, Ordering::SeqCst); + Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root: canonicalize_external_agent_runner_project_root(requested_root)?, + recovered_from_boot_id: Some("previous-boot".to_string()), + }) + }, + ); + assert!(second.ok, "same requestId retry failed: {:?}", second.error); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + let replay = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |_| -> Result { + panic!("cached replay must not claim the owner again") + }, + ); + assert_eq!(replay, second); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} + #[test] fn corrupt_legacy_owner_diagnostic_does_not_block_lock_recovery() { let directory = unique_test_directory(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index 6e80fdbba..9bbdae1bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -31,6 +31,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, ) -> Result<(), String> { let (input_tx, input_rx) = mpsc::channel(); std::thread::spawn(move || { @@ -64,6 +65,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root, parent_agent_id, run_profile, + supervisor_source, &input_rx, &mut output, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index cc4bb3c7a..dd4c51ee4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -44,9 +44,23 @@ pub(super) enum SwarmNewRunLaunch<'a> { ExplicitParentDebug, } +impl SwarmNewRunLaunch<'_> { + pub(super) fn expected_parent_source(self) -> Option<&'static str> { + match self { + Self::ProjectSupervisor { source, .. } + if source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE => + { + Some(source) + } + Self::ProjectSupervisor { .. } | Self::ExplicitParentDebug => None, + } + } +} + pub(super) fn resolve_swarm_new_run_launch<'a>( parent_agent_id: &str, run_profile: &'a str, + supervisor_source: &'static str, ) -> Result, String> { if !matches!( run_profile, @@ -55,11 +69,27 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); } if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if !matches!( + supervisor_source, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) { + return Err(format!( + "不支持的 Project Supervisor source:{supervisor_source}" + )); + } + if supervisor_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("game-chat smoke source 仅支持 autonomous-game-build".to_string()); + } return Ok(SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + source: supervisor_source, run_profile, }); } + if supervisor_source != AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE { + return Err("game-chat smoke source 仅支持 project-supervisor 总控入口".to_string()); + } if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); } @@ -70,6 +100,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, input: &Receiver, output: &mut W, ) -> Result<(), String> { @@ -77,7 +108,9 @@ pub(super) fn run_game_creator_swarm_chat_with_input( if parent_agent_id.is_empty() { return Err("parentAgentId 不能为空".to_string()); } - let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; + let new_run_launch = + resolve_swarm_new_run_launch(parent_agent_id, run_profile, supervisor_source)?; + let expected_parent_source = new_run_launch.expected_parent_source(); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; @@ -103,6 +136,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, active_session_id, run_profile, + expected_parent_source, &existing_runtimes, ) .is_some_and(runtime_is_busy); @@ -152,6 +186,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, &observation.session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -177,6 +212,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index 4cefa163f..40129d9ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -25,12 +25,14 @@ pub(super) fn swarm_parent_runtime<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) }) } @@ -38,21 +40,36 @@ pub(super) fn swarm_parent_runtime_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) - .filter(|runtime| runtime.state.run_id == expected_run_id) + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| runtime.state.run_id == expected_run_id) } pub(super) fn swarm_parent_steer_target<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).filter(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| { expected_run_id.is_none_or(|run_id| runtime.state.run_id == run_id) && game_creator_agent_runtime_accepts_steer(&runtime.state) }) @@ -62,6 +79,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, message: &str, ) -> Option<&'a str> { let message = message.trim(); @@ -72,6 +90,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( .find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" && task.task.trim() == message @@ -83,10 +102,12 @@ pub(super) fn next_pending_swarm_task<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, ) -> Option<&'a AgentRuntimeTaskRecord> { runtime.recent_tasks.iter().find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" }) @@ -96,14 +117,23 @@ pub(super) fn swarm_parent_task_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeTaskRecord> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).and_then(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .and_then(|runtime| { runtime.recent_tasks.iter().find(|task| { task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_run_id }) }) @@ -147,6 +177,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result, String> { @@ -154,6 +185,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) { @@ -167,6 +199,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( if task.agent_id != parent_agent_id || task.session_id != session_id || task.run_profile != run_profile + || expected_source.is_some_and(|source| task.source != source) || task.run_id != expected_run_id { return Err("Swarm parent task journal 与目标 Session/run 不匹配".to_string()); @@ -177,6 +210,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) @@ -188,6 +222,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result { @@ -196,6 +231,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, )? @@ -216,6 +252,7 @@ pub(super) fn swarm_current_runtimes_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Vec { @@ -225,6 +262,7 @@ pub(super) fn swarm_current_runtimes_for_run( (runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) && runtime.state.run_id == expected_run_id) || (runtime.state.parent_agent_id.as_deref() == Some(parent_agent_id) && runtime.state.parent_run_id.as_deref() == Some(expected_run_id)) @@ -340,6 +378,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_parent_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> SwarmTerminalFailureScan { @@ -348,6 +387,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ); @@ -360,6 +400,7 @@ pub(super) fn scan_swarm_terminal_failures_at( if task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_parent_run_id => { Some(task) @@ -379,6 +420,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 2d4fd8f55..f0eda7231 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -64,8 +64,12 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, ] { assert_eq!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_profile,) - .expect("resolve supervisor launch"), + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_profile, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve supervisor launch"), SwarmNewRunLaunch::ProjectSupervisor { source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, run_profile, @@ -77,20 +81,61 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { #[test] fn explicit_parent_debug_keeps_standard_profile_only() { assert_eq!( - resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) - .expect("resolve explicit parent debug launch"), + resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve explicit parent debug launch"), SwarmNewRunLaunch::ExplicitParentDebug, ); assert!(resolve_swarm_new_run_launch( "code-prototype", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, ) .expect_err("autonomous profile must stay on the supervisor root run") .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); - assert!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "unsupported",) - .is_err() + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "unsupported", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_err()); +} + +#[test] +fn restricted_game_chat_smoke_launch_selects_trusted_single_main_source() { + assert_eq!( + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("resolve restricted game-chat smoke launch"), + SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + } ); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + "untrusted-supervisor-source", + ) + .is_err()); } #[test] @@ -270,6 +315,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -280,6 +326,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -287,6 +334,72 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { ); } +#[test] +fn game_chat_smoke_does_not_steer_a_cli_source_runtime() { + let session_id = "session-source-isolation"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let mut old_cli = runtime("running", "planning", 0); + old_cli.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + old_cli.state.session_id = session_id.to_string(); + old_cli.state.run_id = "run-old-cli".to_string(); + old_cli.state.run_profile = run_profile.to_string(); + old_cli.state.source = AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE.to_string(); + old_cli.recent_tasks.push( + serde_json::from_value(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-old-cli-pending", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "生成完整可玩游戏", + "status": "pending", + "phase": "queued" + })) + .expect("deserialize old CLI pending task"), + ); + let runtimes = vec![old_cli]; + + assert!(swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + None, + &runtimes, + ) + .is_none()); + assert!(swarm_parent_runtime( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + &runtimes, + ) + .is_none()); + assert!(matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + "生成完整可玩游戏", + ) + .is_none()); + assert_eq!( + swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + None, + None, + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-old-cli"), + "ordinary CLI lookup must preserve its existing source-agnostic behavior" + ); +} + #[test] fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { let session_id = "session-pending-after-completed"; @@ -319,13 +432,20 @@ fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { session_id, run_profile, None, + None, &runtimes, ) .is_none(), "queued work must not make the completed canonical run steerable" ); assert_eq!( - matching_pending_swarm_run_id(&runtimes[0], session_id, run_profile, "继续补齐游戏功能",), + matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + None, + "继续补齐游戏功能", + ), Some("run-pending-after-completed") ); } @@ -465,6 +585,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -474,6 +595,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-next-running", &runtimes, ) @@ -483,6 +605,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -509,6 +632,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("run-running-steer"), &runtimes, ) @@ -519,6 +643,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("another-run"), &runtimes, ) @@ -807,6 +932,7 @@ fn active_turn_eof_keeps_observing_until_parent_completes() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, conversation_baseline, &rx, &mut output, @@ -1083,6 +1209,7 @@ fn wait_for_turn_keeps_consecutive_run_reply_and_report_scoped() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, baseline, &rx, &mut output, @@ -1358,6 +1485,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child.clone()], ); @@ -1386,6 +1514,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child], ); @@ -1548,6 +1677,7 @@ fn failure_scan_reads_all_historical_runs_for_the_same_specialist() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), current_specialist, current_quality], ); @@ -1595,6 +1725,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, "run-new-pending", &runtimes, ); @@ -1607,6 +1738,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, &old_parent.state.run_id, &runtimes, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index 531b0b4c6..7d98a5cd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -10,6 +10,7 @@ pub(super) fn handle_swarm_user_turn( input: &Receiver, output: &mut W, ) -> Result { + let expected_parent_source = new_run_launch.expected_parent_source(); let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; let active_goal_run_id = @@ -49,6 +50,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -57,6 +59,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -75,15 +78,34 @@ pub(super) fn handle_swarm_user_turn( .last() .is_some_and(|item| item.role == "user" && item.content.trim() == message.trim()); let matching_pending_run_id = pending_message_is_latest - .then(|| swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes)) + .then(|| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + }) .flatten() .and_then(|runtime| { - matching_pending_swarm_run_id(runtime, session_id, run_profile, message) + matching_pending_swarm_run_id( + runtime, + session_id, + run_profile, + expected_parent_source, + message, + ) }) .map(str::to_string); - let parent_has_queued_work = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) - .is_some_and(runtime_is_busy); + let parent_has_queued_work = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + .is_some_and(runtime_is_busy); if parent_has_queued_work { require_external_agent_runner_for_cli_runtime_write(root)?; resume_game_creator_agent_background_tasks_at(root)?; @@ -104,6 +126,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -114,6 +137,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -122,6 +146,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -155,6 +180,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, None, ¤t_runtimes, ) { @@ -163,6 +189,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -226,6 +253,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, @@ -339,6 +367,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, run_id: &str, message: &str, previous_message_count: usize, @@ -371,6 +400,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -424,6 +454,7 @@ fn start_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + new_run_launch.expected_parent_source(), conversation_baseline, input, output, @@ -448,6 +479,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, previous_message_count: usize, input: &Receiver, output: &mut W, @@ -459,14 +491,18 @@ pub(super) fn handle_swarm_resume_turn( .map_err(|error| format!("写入终端失败:{error}"))?; } let current_runtimes = read_game_creator_agent_runtimes_at(root)?; - let Some(parent) = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) - else { + let Some(parent) = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + ¤t_runtimes, + ) else { writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") .map_err(|error| format!("写入终端失败:{error}"))?; return Ok(SwarmChatFlow::Continue); }; - let pending = next_pending_swarm_task(parent, session_id, run_profile); + let pending = next_pending_swarm_task(parent, session_id, run_profile, expected_parent_source); let (target_run_id, target_status, target_phase) = if game_creator_agent_runtime_accepts_steer(&parent.state) || parent.state.status == "pending" @@ -506,6 +542,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -517,6 +554,7 @@ fn wait_and_print_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -527,6 +565,7 @@ fn wait_and_print_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 994acd3e2..59d03b910 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -14,6 +14,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -31,6 +32,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -110,6 +112,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -154,6 +157,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? && (!runner.enabled || !runner.running) @@ -175,6 +179,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? { @@ -208,6 +213,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ) @@ -217,6 +223,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )?; @@ -343,6 +350,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, Some(&conversation_baseline.parent_run_id), &runtimes, ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 7e321f4c9..cb0c43cfb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1217,6 +1217,11 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() { #[test] fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-semantic-gate-test-user", + "visual-semantic-gate-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-semantic-gate-test-key"}}"#.to_string(), ); @@ -1608,6 +1613,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r request_sender, generation_release_receiver, ); + let _platform_session = crate::platform_session::install_test_platform_session( + "canvas-replacement-user", + "editor-runtime-key", + &canvas_base_url, + ); init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); @@ -1800,7 +1810,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { + if captured.starts_with("POST /api/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -2231,6 +2241,11 @@ fn gui_and_cli_autonomous_runs_still_allow_publish_delegates() { #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-delegation-contract-user", + "visual-delegation-contract-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 392033686..c4f225782 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -919,6 +919,11 @@ fn project_supervisor_runtime_id_is_normalized_and_collected() { #[test] fn visual_specialist_prompts_require_real_registered_image_deliveries() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-prompt-test-user", + "visual-prompt-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-prompt-test-key"}}"#.to_string(), ); @@ -1053,6 +1058,11 @@ fn visual_prompts_degrade_to_text_contracts_without_editor_api_key() { #[tokio::test] async fn project_supervisor_prompts_are_total_control_and_reject_isolated_template() { let planning_prompt = { + let _platform_session = crate::platform_session::install_test_platform_session( + "supervisor-prompt-test-user", + "supervisor-prompt-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"supervisor-prompt-test-key"}}"#.to_string(), ); @@ -1207,7 +1217,7 @@ fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy assert_eq!(specialist.api_key, "global-key"); assert_eq!(specialist.base_url, "https://global.example.test/v1"); assert_eq!(specialist.model, "global-model"); - assert!(!specialist.stream); + assert!(specialist.stream); } #[test] @@ -2777,6 +2787,395 @@ fn completed_child_final_response_becomes_needs_user_input_delivery_result() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_root_converts_child_user_input_request_to_safe_default_repair() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-safe-default-repair", + "game-chat 自主构建安全默认返工测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-safe-default-parent-session", + parent_run_id, + "game-chat-safe-default-parent-action", + "game-chat-safe-default-delegation", + "design-director", + "game-chat-safe-default-child-session", + "game-chat-safe-default-child-run", + &["自主选择安全默认方案继续交付".to_string()], + &[], + None, + ); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind delegated child"); + let sensitive_question = "请把 secret token 和绝对路径 D:/private/game 发给我"; + let response = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_input", + "header": "敏感输入", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "提供敏感信息。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "design-director".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主设计决策".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: Some(response.clone()), + error: None, + updated_at: unix_timestamp(), + }; + + let mut result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + &response, + ) + .expect("build trusted game-chat child result"); + let parent_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + agent_id: delivery.parent_agent_id.clone(), + session_id: delivery.parent_session_id.clone(), + run_id: delivery.parent_run_id.clone(), + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + run_profile_binding_fingerprint: parent_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "自主生成游戏".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "等待专业 Agent".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + assert!( + trusted_game_chat_autonomous_root_parent_at(&root, &parent_task) + .expect("validate trusted game-chat parent") + ); + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(result.user_input_questions.is_empty()); + assert!(result.user_input_questions_sha256.is_none()); + let error = result.error.expect("safe-default repair summary"); + let summary: Value = serde_json::from_str(&error).expect("structured safe-default summary"); + assert_eq!(summary["schemaVersion"], "game-chat-safe-default-repair.v1"); + assert_eq!(summary["code"], "child-needs-user-input"); + assert_eq!(summary["strategy"], "continue-with-safe-defaults"); + assert!(error.chars().count() <= 500); + assert!(!error.contains(sensitive_question)); + assert!(!error.contains("D:/private/game")); + assert!(!error.contains("API Key")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_safe_default_decision_is_allowlisted_and_fail_closed() { + let question = |id: &str, header: &str, text: &str| AgentRuntimeUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: text.to_string(), + options: vec![ + AgentRuntimeUserInputOption { + label: "默认".to_string(), + description: "使用平台已有的默认值。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "跳过".to_string(), + description: "跳过当前不确定项。".to_string(), + }, + ], + }; + let convert = |question: AgentRuntimeUserInputQuestion| { + let questions = vec![question]; + let sha256 = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&questions).expect("serialize question")) + ); + let mut result = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::NeedsUserInput, + user_input_questions: questions, + user_input_questions_sha256: Some(sha256), + ..StaticDelegateStructuredResult::default() + }; + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + let marker = + serde_json::from_str::(result.error.as_deref().expect("safe-default marker")) + .expect("parse safe-default marker"); + let instruction = game_chat_safe_default_repair_task_instruction(&result) + .expect("safe-default task instruction"); + (marker, instruction) + }; + + let (preference, preference_instruction) = convert(question( + "visual_preference", + "视觉偏好", + "首版采用明亮还是柔和配色?", + )); + assert_eq!(preference["reasonCode"], "preference-clarification"); + assert_eq!(preference["defaultDecision"], "use-safe-default"); + assert!(preference_instruction.contains("use-safe-default")); + + for question in [ + question("unknown_fact", "补充信息", "请告诉我还缺什么再继续。"), + question("secret", "接入信息", "请提供 API Key 后继续。"), + question("permission", "外部操作", "是否允许将结果发布到外部平台?"), + ] { + let (marker, instruction) = convert(question); + assert_eq!(marker["reasonCode"], "sensitive-or-permission-request"); + assert_eq!(marker["defaultDecision"], "skip-denied"); + assert!(instruction.contains("skip-denied")); + assert!(instruction.contains("不得再次询问用户")); + } +} + +#[test] +fn legacy_claimed_game_chat_user_input_delivery_recovers_as_repair_without_pending_question() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-legacy-safe-default-repair", + "game-chat legacy 澄清恢复测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-legacy-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "自主完成可玩游戏", + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "等待专业 Agent", + vec!["收齐专业回执".to_string()], + ) + .expect("start trusted game-chat parent"); + state.run_profile = parent_binding.profile.clone(); + state.run_profile_binding_fingerprint = parent_binding.binding_fingerprint.clone(); + write_game_creator_agent_runtime_state(&root, &state).expect("persist bound game-chat state"); + append_game_creator_agent_runtime_task(&root, &state).expect("persist bound game-chat task"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + parent_run_id, + "game-chat-legacy-parent-action", + &agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "code-prototype", + "game-chat-legacy-parent-action", + ), + "code-prototype", + "game-chat-legacy-child-session", + "game-chat-legacy-child-run", + &["采用安全默认值继续完成设计".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create legacy delivery"); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind legacy delegated child"); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: delivery.target_agent_id.clone(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主代码原型".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task( + &root, + &agent_runtime_state_from_task_record(&child_task), + ) + .expect("persist legacy delegated child task"); + let sensitive_question = "请提供 C:/secret/token.txt 内的 token"; + let result = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_token", + "header": "密钥", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "读取秘密文件。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + )), + ) + .expect("build legacy needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要敏感用户输入", + result, + ) + .expect("mark legacy delivery ready"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration policy"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "game-chat-legacy-claim-action", + ) + .expect("claim legacy delivery"); + let deliveries = claimed_static_delegate_deliveries_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read legacy claimed delivery"); + + assert!( + !ensure_static_delegate_user_input_wait_at(&root, &mut state, &deliveries,) + .expect("convert legacy clarification") + ); + let converted = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read converted delivery") + .expect("converted delivery exists"); + let converted_result = converted.structured_result.expect("converted result"); + assert_eq!( + converted_result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(converted_result.user_input_questions.is_empty()); + assert!(converted_result.user_input_questions_sha256.is_none()); + let error = converted_result.error.expect("safe-default repair summary"); + assert!(!error.contains(sensitive_question)); + assert!(read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .is_err()); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read converted barrier"); + assert_eq!(barrier.user_input_required_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_run_status_replays_receipts_for_same_action() { let root = unique_project_path(); @@ -3166,7 +3565,7 @@ fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() assert_eq!(wrong_parent.status, "failed"); assert!(wrong_parent .summary - .contains("不属于当前 Supervisor 父 run")); + .contains("不属于当前可信父 run 或尚未认领")); assert!(wrong_parent.detail.is_none()); let unclaimed_delegation_id = "project-supervisor-claimed-contract-unclaimed"; @@ -3215,7 +3614,9 @@ fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() &serde_json::json!({"delegationId": original_delegation_id}), ); assert_eq!(specialist_query.status, "failed"); - assert!(specialist_query.summary.contains("只有 Project Supervisor")); + assert!(specialist_query + .summary + .contains("不属于当前可信父 run 或尚未认领")); assert!(specialist_query.detail.is_none()); let mut invalid_repair_criteria = observed_criteria.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index 3fead9ed2..ef285d0e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -894,6 +894,10 @@ async fn autonomous_supervisor_delegates_failed_playtest_repair_after_collaborat "自主构建总控委派试玩修复测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test( + &root, + "autonomous supervisor delegated playtest repair", + ); let run_id = "autonomous-supervisor-delegated-playtest-repair-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -1081,6 +1085,7 @@ fn supervisor_preview_tracks_specialist_revision_without_fake_mutation() { "总控验收专业产物验证门测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "supervisor specialist preview gate"); let supervisor_run_id = "supervisor-specialist-preview-run"; let first_revision = prepare_agent_runtime_project_mutation_locked( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b06cfa55b..ad5d30974 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1,5 +1,65 @@ use super::*; +#[test] +fn static_smoke_finish_rejects_same_revision_entry_rewrite_before_credential_binding() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-static-smoke-toctou", + "静态验证入口摘要时序测试", + ) + .expect("project init"); + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write initially valid game entry"); + let run_id = "static-smoke-toctou-run"; + let _lock = acquire_project_write_lock(&root, "test.static-smoke.toctou") + .expect("acquire project write lock"); + prepare_agent_runtime_project_mutation_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "file.write", + ) + .expect("prepare project mutation"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "game.static_smoke", + ) + .expect("begin static smoke"); + + fs::write( + root.join("game/index.html"), + "unverified replacement", + ) + .expect("replace entry without advancing revision"); + let error = + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect_err("replacement bytes must be smoke-validated before credential binding"); + assert!(error.contains("game.static_smoke"), "{error}"); + + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read failed static smoke gate"); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.static_smoke_verified_revision, None); + assert_eq!(gate.static_smoke_verified_game_index_sha256, None); + assert_eq!( + gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) + ); + + drop(_lock); + fs::remove_dir_all(root).ok(); +} + #[test] fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { let root = unique_project_path(); @@ -8,6 +68,11 @@ fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("write default collaboration policy"); let run_id = "preview-gate-failure-run"; + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write smoke-valid game entry"); let revision = prepare_agent_runtime_project_mutation_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -2809,6 +2874,30 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { fs::remove_dir_all(root).ok(); } +#[test] +fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "脚本语法优先验证测试").expect("project init"); + let html = r#" + + +

目标:完成订单。胜利或失败后可以重开。

+ + +"#; + fs::write(root.join("game/index.html"), html).expect("write invalid JavaScript game html"); + + let error = run_limited_local_command_at(&root, "game.static_smoke") + .expect_err("invalid JavaScript must fail before canvas keywords"); + + assert!(error.contains("不是有效 JavaScript"), "{error}"); + assert!(!error.contains("可渲染画布"), "{error}"); + fs::remove_dir_all(root).ok(); +} + #[test] fn limited_local_command_rejects_token_rich_truncated_script() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index f0a5d3ba2..edd287d55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -107,14 +107,50 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() { default_config.agent_mode, GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER ); + assert!( + default_config.llm.stream, + "新的全局 LLM 配置默认使用流式响应" + ); + + let inherited_agent_llm = + resolve_game_creator_llm_config_for_agent(&default_config, "design-director"); + assert!( + inherited_agent_llm.stream, + "未覆盖时 Agent 必须继承全局流式默认" + ); + + let mut explicitly_non_streaming_config = default_config.clone(); + explicitly_non_streaming_config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + stream: Some(false), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !resolve_game_creator_llm_config_for_agent( + &explicitly_non_streaming_config, + "design-director", + ) + .stream, + "Agent 显式关闭流式请求时必须覆盖全局默认" + ); let root = unique_project_path(); fs::create_dir_all(&root).expect("runtime config dir"); let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); - fs::write(&config_path, r#"{"agentMode":"provider"}"#).expect("write explicit provider mode"); + fs::write( + &config_path, + r#"{"agentMode":"provider","llm":{"stream":false}}"#, + ) + .expect("write explicit provider mode and non-stream override"); let mut config = GameCreatorAppConfig::default(); merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode"); assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER); + assert!( + !config.llm.stream, + "已有配置显式关闭流式请求时必须保留该选择" + ); assert_eq!( normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) .expect("app-server mode"), @@ -503,7 +539,7 @@ fn runtime_config_read_returns_defaults_when_file_is_missing() { result.config.llm.reasoning_effort, DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT ); - assert!(!result.config.llm.stream); + assert!(result.config.llm.stream); assert!(!result.config.llm.web_search_enabled); assert_eq!( result.config.llm.context_window_tokens, @@ -615,8 +651,12 @@ fn app_config_commands_write_runtime_config_file() { Some(false) ); assert!(!saved.config.agent_llm.contains_key("generator")); - assert_eq!(saved.config.editor_api.api_key, "editor-key"); + assert_eq!(saved.config.editor_api.api_key, ""); assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); + let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME)) + .expect("read persisted runtime config"); + assert!(!persisted.contains("editorApi")); + assert!(!persisted.contains("editor-key")); let read_back = read_game_creator_app_config().expect("read runtime config"); assert_eq!(read_back.config.llm.model, "runtime-model"); @@ -1059,7 +1099,13 @@ fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() { parse_game_creator_llm_reasoning_effort("high"), Ok(Some(platform_llm::LlmResponseReasoningEffort::High)) ); - assert!(parse_game_creator_llm_reasoning_effort("maximum").is_err()); + assert_eq!( + parse_game_creator_llm_reasoning_effort("max"), + Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)) + ); + let error = parse_game_creator_llm_reasoning_effort("maximum") + .expect_err("maximum is not the max wire value"); + assert!(error.contains("max")); let mut llm = GameCreatorLlmConfig::default(); llm.reasoning_effort = "default".to_string(); @@ -1068,13 +1114,13 @@ fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() { .expect("provider default request"); assert_eq!(request.response_reasoning_effort, None); - llm.reasoning_effort = "high".to_string(); + llm.reasoning_effort = "max".to_string(); let request = apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm) - .expect("high reasoning request"); + .expect("max reasoning request"); assert_eq!( request.response_reasoning_effort, - Some(platform_llm::LlmResponseReasoningEffort::High) + Some(platform_llm::LlmResponseReasoningEffort::Max) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 3c3c80b4f..6e8a81739 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1232,6 +1232,14 @@ pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard { config .entry("agentMode") .or_insert_with(|| serde_json::json!(GAME_CREATOR_AGENT_MODE_PROVIDER)); + let llm = config.entry("llm").or_insert_with(|| serde_json::json!({})); + if !llm.is_object() { + *llm = serde_json::json!({}); + } + llm.as_object_mut() + .expect("test config LLM value must be an object") + .entry("stream") + .or_insert_with(|| serde_json::json!(false)); } serde_json::to_vec_pretty(&config).expect("serialize explicit Provider test config") } @@ -1245,6 +1253,27 @@ pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard { } } +#[test] +fn test_local_config_defaults_mock_provider_to_non_streaming_and_preserves_explicit_stream() { + let guard = + write_test_local_config(r#"{"agentMode":"codex_cli","llm":{"stream":true}}"#.to_string()); + let explicit_stream = serde_json::from_slice::( + &fs::read(test_local_config_path()).expect("read explicit streaming test config"), + ) + .expect("parse explicit streaming test config"); + assert_eq!(explicit_stream["agentMode"], "codex_cli"); + assert_eq!(explicit_stream["llm"]["stream"], true); + drop(guard); + + let _guard = write_test_local_config(r#"{"agentLlm":{}}"#.to_string()); + let defaulted = serde_json::from_slice::( + &fs::read(test_local_config_path()).expect("read defaulted mock provider test config"), + ) + .expect("parse defaulted mock provider test config"); + assert_eq!(defaulted["agentMode"], GAME_CREATOR_AGENT_MODE_PROVIDER); + assert_eq!(defaulted["llm"]["stream"], false); +} + fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { let lock = TEST_CONFIG_LOCK .lock() @@ -1457,6 +1486,15 @@ fn fake_llm_game_draft() -> LlmGameDraft { } } +fn write_static_smoke_game_fixture_for_test(root: &Path, marker: &str) { + let html = format!( + "{}\n\n", + fake_llm_game_draft().game_html + ); + fs::write(root.join("game/index.html"), html) + .expect("write complete static-smoke game fixture"); +} + fn fake_agent_handoffs() -> Vec { vec![ handoff( @@ -3096,17 +3134,23 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( let normalized_request = request.to_ascii_lowercase(); let (status, content_type, body) = if request .starts_with("GET /api/external/v1/editor/projects ") + || request.starts_with("GET /api/editor/projects ") { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", projects_body.as_bytes().to_vec()) - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") + || request.starts_with("GET /api/editor/assets/library ") + { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", library_body.as_bytes().to_vec()) } else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ") + || request.starts_with("GET /api/editor/projects/canvas-project-1 ") { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", project_body.as_bytes().to_vec()) - } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { + } else if request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/images/generations ") + { assert!(normalized_request.contains("authorization: bearer ")); let idempotency_key = request .lines() @@ -3127,6 +3171,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( ) } else if request .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") { assert!(normalized_request.contains("authorization: bearer ")); let idempotency_key = request @@ -3148,6 +3193,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( ) } else if request.starts_with( "GET /api/external/v1/generations/task-external-fixture-1 ", + ) || request.starts_with( + "GET /api/runtime/external-generation/jobs/task-external-fixture-1 ", ) { assert!(normalized_request.contains("authorization: bearer ")); if let Some(gate) = generation_response_gate.take() { @@ -3189,11 +3236,15 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( ) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", + ) || request.starts_with( + "GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", read_body.as_bytes().to_vec()) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", + ) || request.starts_with( + "GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ( @@ -3204,6 +3255,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } else if let Some(index) = (0..4).find(|index| { request.starts_with(&format!( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet-slice-{index}.png " + )) || request.starts_with(&format!( + "GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet-slice-{index}.png " )) }) { assert!(normalized_request.contains("authorization: bearer ")); @@ -3278,7 +3331,10 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { .contains("authorization: bearer ")); let (status, body) = match index { 0 => { - assert!(request.starts_with("GET /api/external/v1/editor/projects ")); + assert!( + request.starts_with("GET /api/external/v1/editor/projects ") + || request.starts_with("GET /api/editor/projects ") + ); ( "200 OK", serde_json::json!({ @@ -3291,7 +3347,10 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { ) } 1 => { - assert!(request.starts_with("GET /api/external/v1/editor/assets/library ")); + assert!( + request.starts_with("GET /api/external/v1/editor/assets/library ") + || request.starts_with("GET /api/editor/assets/library ") + ); ( "200 OK", serde_json::json!({ @@ -3304,7 +3363,10 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { ) } _ => { - assert!(request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!( + request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/images/generations ") + ); ( "500 Internal Server Error", "{\"error\":\"generation failed\"}".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1fda1fa86..671ac80ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -651,6 +651,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let config_dir = unique_project_path(); let (canvas_sender, canvas_receiver) = mpsc::channel(); let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(canvas_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "background-art-user", + "editor-runtime-key", + &canvas_base_url, + ); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( @@ -688,6 +693,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), serde_json::json!({ "agentMode": "provider", + "llm": { "stream": false }, "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" @@ -835,14 +841,12 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .collect::>(); let generation_request = canvas_requests .iter() - .find(|request| { - request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") - }) + .find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations ")) .expect("canvas generation request"); assert_eq!( canvas_requests .iter() - .filter(|request| request.starts_with("POST /api/external/v1/editor/")) + .filter(|request| request.starts_with("POST /api/editor/")) .count(), 1, "queued/running polling must not submit generation again" @@ -850,7 +854,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { assert_eq!( canvas_requests .iter() - .filter(|request| request.starts_with("GET /api/external/v1/generations/")) + .filter(|request| request.starts_with("GET /api/runtime/external-generation/jobs/")) .count(), 3, "fixture should exercise queued, running, and completed states" @@ -889,6 +893,11 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { let (request_sender, request_receiver) = mpsc::channel(); let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "canonical-art-user", + "editor-runtime-key", + &canvas_base_url, + ); fs::create_dir_all(config_dir).expect("create runtime config dir"); fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), @@ -938,7 +947,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { }, ) .await; - assert!(spec_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(spec_request.starts_with("POST /api/editor/images/generations ")); assert!(spec_request.contains(r#""kind":"spec""#)); assert!(spec_request.contains(r#""assetKind":"icon-spec""#)); assert!(spec_request.contains(r#""referenceImageSrcs":[]"#)); @@ -958,7 +967,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { }, ) .await; - assert!(ui_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(ui_request.starts_with("POST /api/editor/images/generations ")); assert!(ui_request.contains(r#""kind":"ui-design""#)); assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); @@ -977,6 +986,11 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m request_sender, generation_release_receiver, ); + let _platform_session = crate::platform_session::install_test_platform_session( + "project-lock-art-user", + "editor-runtime-key", + &canvas_base_url, + ); init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); @@ -1025,7 +1039,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { + if captured.starts_with("POST /api/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -1871,6 +1885,11 @@ fn import_canvas_export_zip_copies_files_and_registers_assets() { async fn sync_canvas_project_assets_downloads_external_resources() { let root = unique_project_path(); let base_url = spawn_mock_external_canvas_api_server(); + let _platform_session = crate::platform_session::install_test_platform_session( + "canvas-sync-user", + "test-editor-api-key", + &base_url, + ); let result = sync_canvas_project_assets_at( &root, @@ -1918,6 +1937,11 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); + let _platform_session = crate::platform_session::install_test_platform_session( + "platform-art-user", + "editor-unit-key", + &base_url, + ); init_local_game_project_at(&root, "project-platform-art", "未命名游戏原型") .expect("project init"); fs::create_dir_all(&config_dir).expect("runtime config dir"); @@ -1998,6 +2022,11 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { .expect("project init"); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_failure_server(); + let _platform_session = crate::platform_session::install_test_platform_session( + "platform-art-fallback-user", + "editor-fallback-secret", + &base_url, + ); fs::create_dir_all(&config_dir).expect("runtime config dir"); fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), @@ -2839,6 +2868,7 @@ fn preview_path_rejects_traversal() { fs::create_dir_all(project.join("memory")).expect("memory dir"); fs::create_dir_all(project.join(".agent")).expect("agent dir"); fs::write(project.join("game/index.html"), "").expect("index"); + fs::write(project.join("game/style.css"), "body {}").expect("game style"); fs::write(project.join("assets/player.png"), b"png").expect("asset"); fs::write(project.join("memory/project.md"), "secret memory").expect("memory"); fs::write(project.join(".agent/run.latest.json"), "{}").expect("trace"); @@ -2861,6 +2891,13 @@ fn preview_path_rejects_traversal() { .canonicalize() .expect("canonical asset") ); + assert_eq!( + resolve_preview_path(&project, "/style.css?cache=1").unwrap(), + project + .join("game/style.css") + .canonicalize() + .expect("canonical game style") + ); fs::remove_dir_all(project).ok(); } @@ -2928,11 +2965,6 @@ fn launcher_window_uses_launcher_route() { assert_eq!(launcher_window_url().to_string(), "index.html?launcher"); } -#[test] -fn developer_window_uses_agent_chat_route() { - assert_eq!(developer_window_url().to_string(), "index.html?agent-chat"); -} - #[test] fn workspace_window_url_carries_encoded_project_path() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 0855d3403..eacec8589 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5686,6 +5686,73 @@ fn runtime_parallel_read_projection_waits_across_a_manifest_wave_project_write() fs::remove_dir_all(root).ok(); } +#[cfg(windows)] +#[test] +fn runtime_project_write_lock_waits_for_delete_pending_target() { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + const DELETE_ACCESS: u32 = 0x0001_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "delete pending project lock wait") + .expect("project init"); + let lock_path = root.join(PROJECT_WRITE_LOCK_PATH); + fs::write(&lock_path, "delete-pending-project-lock").expect("write lock fixture"); + + let delete_pending = fs::OpenOptions::new() + .access_mode(GENERIC_READ | DELETE_ACCESS) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + .expect("open delete-pending lock fixture"); + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: delete_pending is live and disposition has the required layout. + assert_ne!( + unsafe { + SetFileInformationByHandle( + delete_pending.as_raw_handle().cast(), + FileDispositionInfo, + (&raw const disposition).cast(), + std::mem::size_of::() as u32, + ) + }, + 0, + "mark project lock delete-pending: {}", + std::io::Error::last_os_error() + ); + let create_error = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&lock_path) + .expect_err("delete-pending target must block the create_new contention path"); + assert!( + create_error.kind() == std::io::ErrorKind::PermissionDenied + || create_error.raw_os_error() == Some(5), + "delete-pending create_new should reproduce ACCESS_DENIED: {create_error}" + ); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + drop(delete_pending); + }); + + let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "runtime.parallel_read.project", + ) + .expect("runtime projection should outwait a delete-pending project lock"); + drop(acquired); + release.join().expect("release delete-pending lock fixture"); + assert!(!lock_path.exists()); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_file_write_waits_for_short_parallel_project_writer() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index e7f0cf67d..b287564d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -1625,6 +1625,10 @@ async fn autonomous_game_build_repairs_persisted_failed_playtest_after_context_c "自主构建总控持久试玩失败活性测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test( + &root, + "persisted failed playtest after context compaction", + ); write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("disable unrelated collaboration preflight"); let run_id = "autonomous-supervisor-persisted-playtest-liveness-run"; @@ -6726,6 +6730,7 @@ async fn agent_loop_writes_spec_findings_and_retries_generator() { api_key: "test-key".to_string(), base_url, model: "mock-game-model".to_string(), + stream: false, ..GameCreatorLlmConfig::default() }, ..GameCreatorAppConfig::default() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index b0dadfb3f..03665afb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -982,6 +982,11 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof #[test] fn visual_specialist_finalization_requires_existing_registered_canvas_image() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-finalization-test-user", + "visual-finalization-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-finalization-test-key"}}"#.to_string(), ); @@ -4033,6 +4038,179 @@ async fn provider_handoff_final_reply_restart_replays_success_without_network_re fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn project_execution_owner_cross_boot_replays_same_run_provider_handoff_to_completion_once() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-owner-provider-handoff", + "execution owner 跨 boot Provider 恢复测试", + ) + .expect("project init"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create cross-boot Provider config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + fs::create_dir_all(injection.parent().expect("handoff injection parent")) + .expect("create handoff injection parent"); + fs::write(&injection, b"stop-after-final-reply-provider-handoff") + .expect("write final reply handoff injection"); + let agent_id = "design-director"; + let run_id = "owner-cross-boot-provider-handoff-run"; + let final_response = "新 boot 从原 Provider handoff 恢复并唯一完成。"; + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let (request_capture_sender, request_capture_receiver) = mpsc::channel(); + let (base_url, server_handle) = spawn_mock_llm_tool_plan_then_transient_final_reply( + final_tool_plan_response("不得提交 planning fallback。"), + final_response.to_string(), + request_notice_sender, + request_capture_sender, + ); + replace_test_local_config( + &config_path, + format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "design-director": {{ + "apiKey": "owner-cross-boot-provider-key", + "baseUrl": {base_url:?}, + "model": "owner-cross-boot-provider-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 1, + "retryBackoffMs": 1000 + }} + }} +}}"# + ), + ); + let started = start_game_creator_agent_background_task_at( + &root, + agent_id, + "验证新 execution owner 恢复纯 Provider durable run", + run_id, + ) + .expect("start cross-boot Provider task"); + + for _ in 0..3 { + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request before simulated old Runner exit"); + } + let handoff = wait_for_provider_handoff_test_stop(&root, agent_id, run_id); + server_handle.join().expect("join cross-boot Provider mock"); + let captured_requests = (0..3) + .map(|_| { + request_capture_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("captured Provider request before ownership transfer") + }) + .collect::>(); + assert_eq!(captured_requests.len(), 3); + assert!(request_capture_receiver.try_recv().is_err()); + + let (claimed_root, recovered_from_boot_id) = + crate::runner::simulate_external_agent_runner_cross_boot_owner_claim_for_test( + &root, + &config_dir, + "owner-provider-boot-a", + "owner-provider-boot-b", + ) + .expect("new boot claims and resumes Provider handoff"); + assert_eq!(claimed_root, root); + assert_eq!( + recovered_from_boot_id.as_deref(), + Some("owner-provider-boot-a") + ); + + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + agent_id, + run_id, + "idle", + "completed", + ); + assert_eq!(completed.state.session_id, started.state.session_id); + assert_eq!( + completed.state.last_response.as_deref(), + Some(final_response) + ); + assert!(request_capture_receiver.try_recv().is_err()); + wait_for_provider_handoff_terminal_cleanup(&root, agent_id, run_id); + + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "started" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "completed" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["runId"] == run_id + && matches!( + record["recordType"].as_str(), + Some("agent.runtime.agent.delegate") + | Some("agent.runtime.tool_action.executing") + ) + }) + .count(), + 0 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read recovered conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == final_response) + .count(), + 1 + ); + assert_eq!( + completed + .recent_tasks + .iter() + .filter(|task| task.run_id == run_id && task.status == "completed") + .count(), + 1 + ); + + assert!(request_capture_receiver.try_recv().is_err()); + let records_after = read_agent_db_records_for_test(&root); + assert_eq!(records_after, records); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn provider_handoff_final_reply_compaction_restart_only_requests_final_reply() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 3cdc569e9..accde9020 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -2044,8 +2044,154 @@ fn preview_validate_public_event_detail_stays_structured_below_event_limit() { fs::remove_dir_all(root).ok(); } +#[test] +fn static_smoke_failure_receipt_keeps_owner_diagnostic_and_redacts_public_detail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断回执测试").expect("project init"); + let observation = AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("public static smoke metadata"); + let public = serde_json::from_str::(&public).expect("parse public metadata"); + assert_eq!(public["commandId"], "game.static_smoke"); + assert_eq!(public["passed"], false); + assert_eq!(public["failureCode"], "static-smoke-failed"); + assert_eq!(public["check"], "javascript-syntax"); + assert_eq!(public["path"], "game/index.html"); + assert!(public.get("diagnostic").is_none()); + + let owner = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + "code-prototype", + "static-smoke-owner-run", + &observation, + ) + .expect("owner static smoke metadata"); + let owner = serde_json::from_str::(&owner).expect("parse owner metadata"); + assert_eq!(owner["failureCode"], "static-smoke-failed"); + assert_eq!(owner["check"], "javascript-syntax"); + assert_eq!(owner["path"], "game/index.html"); + let diagnostic = owner["diagnostic"].as_str().expect("owner diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn static_smoke_failure_receipt_round_trips_owner_diagnostic_from_agent_db() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断持久化测试").expect("project init"); + let run_id = "static-smoke-owner-round-trip-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "修复游戏入口语法并重新验证", + run_id, + "agent-background-task", + "执行静态验证", + vec!["读取失败诊断并修复".to_string()], + ) + .expect("start static smoke owner runtime"); + let action = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("验证当前游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(run_id, 1, 0, 0, &action_fingerprint); + let observation = AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &action_fingerprint, + &action.tool, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("game.static_smoke"), + &observation, + ) + .expect("persist static smoke failure receipt"); + + let records = read_agent_db_records_for_test(&root); + let receipt = records + .iter() + .find(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .expect("persisted static smoke receipt"); + let persisted = serde_json::from_str::( + receipt["safeDetail"] + .as_str() + .expect("persisted owner safe detail"), + ) + .expect("parse persisted owner safe detail"); + assert_eq!(persisted["failureCode"], "static-smoke-failed"); + assert!(persisted["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + assert!(!receipt + .to_string() + .contains(root.to_string_lossy().as_ref())); + + let history = observe_agent_runtime_action_history( + &root, + "code-prototype", + run_id, + &serde_json::json!({ "actionId": action_id, "limit": 1 }), + ); + assert_eq!(history.status, "ok", "{history:?}"); + let history = serde_json::from_str::( + history + .detail + .as_deref() + .expect("round-tripped action history detail"), + ) + .expect("parse round-tripped action history detail"); + let round_tripped = history["actions"][0]["safeDetail"] + .as_str() + .expect("round-tripped owner safe detail"); + let round_tripped = + serde_json::from_str::(round_tripped).expect("parse round-tripped safe detail"); + assert_eq!(round_tripped["commandId"], "game.static_smoke"); + assert_eq!(round_tripped["passed"], false); + assert_eq!(round_tripped["failureCode"], "static-smoke-failed"); + assert_eq!(round_tripped["check"], "javascript-syntax"); + assert_eq!(round_tripped["path"], "game/index.html"); + let diagnostic = round_tripped["diagnostic"] + .as_str() + .expect("round-tripped diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-seed-refresh-test-user", + "visual-seed-refresh-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-seed-refresh-test-key"}}"#.to_string(), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index a2c4def6d..ab4b8b7dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -470,7 +470,11 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ Some(&GameCreationAppTaskStatus::Completed) ); - write_autonomous_editor_api_config_for_test(&config_dir, "editor-runtime-test-key"); + let _platform_session = crate::platform_session::install_test_platform_session( + "autonomous-visual-gate-test-user", + "autonomous-visual-gate-test-key", + "https://dev.genarrative.world", + ); let with_key_missing_images = autonomous_seed_task_statuses_for_test(&root); assert_eq!( with_key_missing_images.get("art-director"), @@ -2113,6 +2117,7 @@ async fn autonomous_game_build_repairs_new_revision_after_failed_playtest_with_v "自主构建总控新 revision 复验测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "reverify new revision after failed playtest"); write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("disable unrelated collaboration preflight"); let run_id = "autonomous-supervisor-reverify-new-revision-run"; @@ -2265,6 +2270,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele "自主构建总控 ready 回执收敛测试", ) .expect("project init"); + write_static_smoke_game_fixture_for_test(&root, "ready delivery initial revision"); let run_id = "autonomous-supervisor-ready-delivery-convergence-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -2332,11 +2338,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele ) .expect("record specialist repair after failed playtest"); assert_eq!(repaired_revision, failed_revision + 1); - fs::write( - root.join("game/index.html"), - "ready delivery revision\n", - ) - .expect("write specialist repair fixture"); + write_static_smoke_game_fixture_for_test(&root, "ready delivery repaired revision"); let deliveries = [ ( @@ -2641,6 +2643,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele &preview_plan, true, false, + false, ) .is_ok()); @@ -2668,6 +2671,7 @@ async fn autonomous_game_build_claims_ready_delivery_before_fourth_playtest_dele &stale_delegate_plan, true, false, + false, ) .expect_err("static-verified revision must reject another stale delegate"); assert!(stale_delegate_error @@ -3484,6 +3488,7 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() .expect("persist verified delivery plan"); write_game_creator_agent_runtime_state(&root, &runtime) .expect("write verified delivery runtime"); + write_static_smoke_game_fixture_for_test(&root, "verified specialist delivery"); advance_project_revision_for_test(&root, "code-prototype", child_run_id, "file.patch"); persist_project_verification_for_test( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5850e0a6a..b99ff0052 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -27,7 +27,7 @@ pub(super) use super::super::{ wait_for_agent_runtime_confirmation, wait_for_agent_runtime_idle, wait_for_agent_runtime_terminal_and_lane_release, wait_for_captured_mock_request, write_agent_runtime_task_record_for_test, write_agent_runtime_verification_fixture, - write_test_local_config, + write_static_smoke_game_fixture_for_test, write_test_local_config, }; pub(super) use crate::{ @@ -43,11 +43,12 @@ pub(super) use crate::{ agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, - append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, - append_game_creator_agent_runtime_task, append_game_creator_agent_runtime_task_projection_once, - append_local_conversation_message_at, append_local_conversation_message_for_session_at, - append_local_permission_log_at, append_or_read_exact_game_creator_agent_runtime_pending_task, - apply_agent_runtime_plan_update, await_game_creator_agent_runtime_provider_request, + append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, + append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, + append_game_creator_agent_runtime_task_projection_once, append_local_conversation_message_at, + append_local_conversation_message_for_session_at, append_local_permission_log_at, + append_or_read_exact_game_creator_agent_runtime_pending_task, apply_agent_runtime_plan_update, + await_game_creator_agent_runtime_provider_request, begin_agent_runtime_project_verification_locked, bind_game_creator_agent_runtime_run_profile_at, build_game_creation_seed_task_graph, build_repository_startup_context_at, cancel_game_creator_agent_runtime_task_at, @@ -65,9 +66,10 @@ pub(super) use crate::{ game_creator_agent_runtime_task_lock_is_available, game_creator_agent_runtime_task_path, game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, - native_runtime_function_name, observe_agent_runtime_agent_delegate, - observe_agent_runtime_agent_message, observe_agent_runtime_agent_spawn_isolated, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + native_runtime_function_name, observe_agent_runtime_action_history, + observe_agent_runtime_agent_delegate, observe_agent_runtime_agent_message, + observe_agent_runtime_agent_spawn_isolated, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index d77a1e92c..d19454339 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -583,6 +583,104 @@ fn structured_plan_update_validates_and_advances_monotonically() { assert!(structured_plan_completion_blocker(&runtime).is_none()); } +#[test] +fn first_structured_plan_replaces_terminal_legacy_scaffolding_steps() { + let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "legacy-plan-run"); + runtime.plan = vec![ + "记录开发者投递的后台任务".to_string(), + "结构化修复步骤 2".to_string(), + ]; + runtime.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "记录开发者投递的后台任务".to_string(), + status: "active".to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "结构化修复步骤 2".to_string(), + status: "pending".to_string(), + detail: Some("legacy-detail-canary".to_string()), + updated_at: 1, + }, + ]; + runtime.active_plan_step_index = Some(0); + complete_agent_runtime_active_plan_step( + &mut runtime, + "failed", + "command.run_limited:failed · game.static_smoke 执行失败", + ); + assert_eq!(runtime.plan_revision, 0); + assert_eq!(runtime.plan_steps.len(), 2); + assert_eq!(runtime.plan_steps[0].status, "failed"); + + let first_structured_update = AgentRuntimePlanUpdate { + explanation: "根据 smoke 诊断进入同 Run 修复".to_string(), + steps: (1..=AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|index| AgentRuntimePlanUpdateStep { + step: format!("结构化修复步骤 {index}"), + status: if index == 1 { "in_progress" } else { "pending" }.to_string(), + }) + .collect(), + }; + + assert!( + apply_agent_runtime_plan_update(&mut runtime, &first_structured_update) + .expect("first structured plan replaces legacy scaffolding") + ); + assert_eq!(runtime.plan_revision, 1); + assert_eq!(runtime.plan_steps.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT); + assert_eq!(runtime.active_plan_step_index, Some(0)); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.title != "记录开发者投递的后台任务")); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.status != "failed")); + let same_title_step = runtime + .plan_steps + .iter() + .find(|step| step.title == "结构化修复步骤 2") + .expect("same-title structured step exists"); + assert_eq!(same_title_step.detail, None); + assert_ne!(same_title_step.updated_at, 1); +} + +#[test] +fn structured_plan_failed_step_remains_immutable_after_migration() { + let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "failed-plan-run"); + runtime.plan_revision = 1; + runtime.plan_explanation = "已进入结构化计划".to_string(); + runtime.plan = vec!["不可改写的失败步骤".to_string()]; + runtime.plan_steps = vec![AgentRuntimePlanStep { + index: 0, + title: "不可改写的失败步骤".to_string(), + status: "failed".to_string(), + detail: Some("非验证类动作失败".to_string()), + updated_at: unix_timestamp(), + }]; + + let error = apply_agent_runtime_plan_update( + &mut runtime, + &AgentRuntimePlanUpdate { + explanation: "尝试错误重开".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "不可改写的失败步骤".to_string(), + status: "in_progress".to_string(), + }], + }, + ) + .expect_err("structured failed step must remain immutable"); + + assert!(error.contains("不能改写已失败步骤")); + assert_eq!(runtime.plan_revision, 1); + assert_eq!(runtime.plan_steps[0].status, "failed"); +} + #[test] fn structured_plan_update_rejects_invalid_shapes_and_ignores_action_indexes() { let invalid_updates = [ @@ -3753,6 +3851,11 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { #[tokio::test] async fn task_update_requires_registered_visual_asset_before_completion() { + let _platform_session = crate::platform_session::install_test_platform_session( + "visual-task-update-test-user", + "visual-task-update-test-key", + "https://dev.genarrative.world", + ); let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index ce93187e4..4236b21dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -469,10 +469,6 @@ pub(crate) fn launcher_window_url() -> tauri::WebviewUrl { tauri::WebviewUrl::App(PathBuf::from("index.html?launcher")) } -pub(crate) fn developer_window_url() -> tauri::WebviewUrl { - tauri::WebviewUrl::App(PathBuf::from("index.html?agent-chat")) -} - pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl { tauri::WebviewUrl::App(PathBuf::from(format!( "index.html?supervisor-chat&projectPath={}", @@ -559,7 +555,7 @@ pub(crate) fn open_game_creator_workspace_window( existing.close().map_err(|error| error.to_string())?; } tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title("AI 游戏创作") + .title("陶泥儿") .inner_size(1180.0, 820.0) .min_inner_size(760.0, 560.0) .build() @@ -580,7 +576,7 @@ pub(crate) fn open_game_creator_launcher_window( existing.set_focus().map_err(|error| error.to_string())?; } else { tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title("AI 游戏创作") + .title("陶泥儿") .inner_size(820.0, 640.0) .min_inner_size(720.0, 520.0) .build() @@ -637,18 +633,3 @@ pub(crate) fn open_project_supervisor_chat_window( Ok(()) } } - -#[cfg(all(debug_assertions, not(test)))] -pub(crate) fn open_developer_window(app: &tauri::AppHandle) -> Result<(), String> { - if let Some(existing) = app.get_webview_window("developer") { - existing.set_focus().map_err(|error| error.to_string())?; - return Ok(()); - } - tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url()) - .title("AI 游戏创作开发") - .inner_size(1040.0, 760.0) - .min_inner_size(780.0, 560.0) - .build() - .map_err(|error| error.to_string())?; - Ok(()) -} diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index fad771711..8b3778615 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -14,7 +14,7 @@ "windows": [ { "label": "client", - "title": "AI 游戏创作", + "title": "陶泥儿", "url": "index.html", "width": 1280, "height": 800, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e91bff851..e9ca0a95b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,7 +32,6 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, - createLocalProjectId, PROJECT_SUPERVISOR_AGENT_ID, PROJECT_SUPERVISOR_PLAN_SOURCE, seedManifest, @@ -102,33 +101,28 @@ import { agentRuntimeStartStatus, agentRuntimeStateFromResult, agentRuntimeSteerStatus, - canArchiveGameChatStage, conversationContainsProjectSupervisorResponseStream, createAgentChatRunId, createDefaultChatMessages, createLocalConversationDraftMessage, ensureProjectSupervisorActiveSessionId, - type GameChatPlayableRevision, - gameChatRuntimeBelongsToLineage, - gameChatRuntimeIdentity, isAgentFinalizationMessageId, isAgentRuntimeTerminalState, - isGameChatSupervisorRoot, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, - latestGameChatPlayableRevision, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, - projectCurrentGameChatRuntimeLineage, projectNameFromPath, projectProfessionalAgentLabel, + projectRuntimeVisibleError, projectSupervisorPendingRepairMatchesProfessional, projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, + resolveProjectSupervisorRuntimeSubmission, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, taskRowsFromManifest, @@ -138,6 +132,7 @@ import { isTransientProjectOpenMessage, latestVisibleItems, persistSupervisorChatDraft, + type ProjectSupervisorComponentProps, readInitialProjectPath, readSupervisorChatDraft, type WorkspaceLauncherProps, @@ -262,6 +257,34 @@ const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v1'; 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; @@ -271,46 +294,37 @@ type GameChatAutoPreviewAuthorization = { runId: string; }; -type PendingGameChatStageArchive = { - rootRuntime: AgentRuntimeState; - runtimeRecords: AgentRuntimeState[]; - manifestSnapshot: GameCreationAppManifest | null; - awaitingConversationSync: boolean; +type GameChatPlayableRevision = { + runId: string; + revision: number; + validatedAt: number; }; -function mergeGameChatRuntimeSnapshots( - previous: AgentRuntimeState[], - incoming: Iterable, -) { - const merged = new Map( - previous.map((runtime) => [gameChatRuntimeIdentity(runtime), runtime]), - ); - for (const runtime of incoming) { - if (!runtime) { - continue; - } - const key = gameChatRuntimeIdentity(runtime); - const existing = merged.get(key); - if (!existing || runtime.updatedAt >= existing.updatedAt) { - merged.set(key, runtime); - } - } - return Array.from(merged.values()); -} +type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { + eventOrder: number; + playable: boolean; +}; -function gameChatManifestHasTerminalPrimaryTask( +const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', + 'art-director', + 'art-asset-plan', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function gameChatManifestHasTerminalStageTasks( manifest: GameCreationAppManifest | null, ) { - const tasks = - manifest?.tasks.filter((task) => task.id === 'code-prototype') ?? []; - return ( - tasks.length === 1 && - (tasks[0]?.status === 'completed' || tasks[0]?.status === 'failed') - ); -} - -function gameChatStageRecordMessageId(runId: string) { - return `game-chat-stage-record:${encodeURIComponent(runId)}`; + if (!manifest) { + return false; + } + return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { + const status = manifest.tasks.find((task) => task.id === taskId)?.status; + return status === 'completed' || status === 'failed'; + }); } function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { @@ -355,6 +369,99 @@ function gameChatPlayableRevisionIsAfterAuthorization( ); } +export function latestGameChatPlayableRevision( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +): GameChatPlayableRevision | null { + if (!runtime?.runId) { + return null; + } + const playtestChildren = new Map(); + for (const child of Object.values(runtimeByAgentId)) { + if ( + child?.agentId !== 'preview-playtest' || + child.taskId !== 'preview-playtest' || + child.source !== 'agent-ready-task-scheduler' || + child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + child.parentRunId !== runtime.runId + ) { + continue; + } + playtestChildren.set( + `${child.agentId}\n${child.sessionId}\n${child.runId}`, + child, + ); + } + let latest: GameChatPreviewValidationCandidate | null = null; + let eventOrder = 0; + for (const child of playtestChildren.values()) { + for (const event of child.recentEvents ?? []) { + eventOrder += 1; + if ( + event.agentId !== child.agentId || + event.taskId !== child.taskId || + event.sessionId !== child.sessionId || + event.runId !== child.runId || + event.eventType !== 'observation' || + !Number.isSafeInteger(event.updatedAt) || + event.updatedAt < 0 || + !event.summary.startsWith('preview.validate:') || + !event.detail?.trim().startsWith('{') + ) { + continue; + } + try { + const detail = JSON.parse(event.detail) as { + passed?: unknown; + playtestPassed?: unknown; + revision?: unknown; + }; + const playable = + event.summary.startsWith('preview.validate:ok') && + detail.passed === true && + detail.playtestPassed === true; + if ( + typeof detail.passed !== 'boolean' || + typeof detail.revision !== 'number' || + !Number.isSafeInteger(detail.revision) || + detail.revision <= 0 + ) { + continue; + } + const shouldReplace = + !latest || + detail.revision > latest.revision || + (detail.revision === latest.revision && + event.updatedAt > latest.validatedAt) || + (detail.revision === latest.revision && + event.updatedAt === latest.validatedAt && + ((latest.playable && !playable) || + (latest.playable === playable && + eventOrder > latest.eventOrder))); + if (!shouldReplace) { + continue; + } + latest = { + eventOrder, + playable, + runId: runtime.runId, + revision: detail.revision, + validatedAt: event.updatedAt, + }; + } catch { + // Ignore malformed or truncated public evidence and wait for a valid revision. + } + } + } + return latest?.playable + ? { + runId: latest.runId, + revision: latest.revision, + validatedAt: latest.validatedAt, + } + : null; +} + function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { try { window.localStorage.removeItem( @@ -476,15 +583,18 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) { export function GameChatReleaseApp({ initialProjectPath = '', initialSupervisorMessage = '', + allowAdvancedExternalEditorConfig = false, }: { initialProjectPath?: string; initialSupervisorMessage?: string; + allowAdvancedExternalEditorConfig?: boolean; }) { return ( ); @@ -494,10 +604,15 @@ type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; + planningMode?: boolean; + orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; + allowAdvancedExternalEditorConfig?: boolean; initialSupervisorMessage?: string; + playRequest?: ProjectSupervisorComponentProps['playRequest']; + onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -514,15 +629,26 @@ export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, initialProjectKind = 'web', + planningMode = false, + orchestrationMode = 'professional-dag', projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, + allowAdvancedExternalEditorConfig = false, initialSupervisorMessage = '', + playRequest = null, + onPlayRequestHandled, onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { + const directCodexProductRuntime = + DIRECT_CODEX_PRODUCT_RUNTIME && + projectSupervisorOnly && + !planningMode && + !supervisorChatOnly && + !gameChatOnly; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -583,13 +709,14 @@ export function App({ const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); const gameChatPendingStageRuntimesRef = useRef( - new Map(), + new Map(), ); const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), }); + const handledPlayRequestRef = useRef(null); function updateClientPreview( nextPreview: LocalPreviewResult | null, @@ -606,16 +733,32 @@ export function App({ : { status }, ); } - const [chatInput, setChatInput] = useState(() => (supervisorChatOnly || gameChatOnly) && initialProjectPath ? readSupervisorChatDraft(initialProjectPath) : '', ); 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] = @@ -646,11 +789,14 @@ export function App({ async (nextProjectPath?: string) => { const targetProjectPath = nextProjectPath?.trim() || localProjectPathRef.current || projectPath; - const invoke = resolveTauriInvoke(); - if (!invoke || !targetProjectPath.trim()) { + if (!targetProjectPath) { setPlanGddState(null); return; } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } const requestSequence = ++planGddHydrateSequenceRef.current; setPlanGddHydrateBusy(true); setPlanGddError(null); @@ -666,10 +812,7 @@ export function App({ setPlanGddState(nextState); } } catch (error) { - if ( - requestSequence === planGddHydrateSequenceRef.current && - localProjectPathRef.current === targetProjectPath - ) { + if (requestSequence === planGddHydrateSequenceRef.current) { setPlanGddError(String(error)); } } finally { @@ -683,27 +826,22 @@ export function App({ const decidePlanGdd = useCallback( async (action: PlanGddDecisionAction, comment: string | null) => { - const current = planGddStateRef.current; - const pending = current?.pendingApproval; + const pending = planGddStateRef.current?.pendingApproval; const targetProjectPath = resolveChatProjectPath(localProject) ?? projectPath; - const invoke = resolveTauriInvoke(); - if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) { - throw new Error('当前没有可提交的 GDD 审批决定'); + if (!pending || !targetProjectPath) { + throw new Error('没有可处理的 GDD 审批请求'); } - // 方案 §13.2:busy、超时与网络重试复用同一 responseId,但用户改变 action 或 - // comment 后必须换新的。旧键只含 `approvalRequestId:action`,改写修改意见时会 - // 带着旧 responseId 提交,命中后端「同 responseId 的审批意图不一致」硬错误。 - // 判据方向是宁可多换不可少换:多换的最坏后果是 receipt 已存在时把 replayed 降级 - // 成 already-decided,两者都是 Ok;少换是硬错误。 - const previousDecision = planGddDecisionResponseIdsRef.current.get( + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + const previous = planGddDecisionResponseIdsRef.current.get( pending.approvalRequestId, ); const responseId = - previousDecision && - previousDecision.action === action && - previousDecision.comment === comment - ? previousDecision.responseId + previous && previous.action === action && previous.comment === comment + ? previous.responseId : `gdd-response-${crypto.randomUUID()}`; planGddDecisionResponseIdsRef.current.set(pending.approvalRequestId, { action, @@ -711,7 +849,6 @@ export function App({ responseId, }); setPlanGddDecisionBusy(true); - setPlanGddError(null); try { await invoke('decide_game_creator_plan_gdd', { projectPath: targetProjectPath, @@ -725,15 +862,6 @@ export function App({ comment, }); await hydratePlanGddState(targetProjectPath); - } catch (error) { - // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 - // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 - // `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。 - // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, - // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 - await hydratePlanGddState(targetProjectPath); - setPlanGddError(String(error)); - throw error; } finally { setPlanGddDecisionBusy(false); } @@ -750,41 +878,6 @@ export function App({ } void hydratePlanGddState(targetProjectPath); }, [hydratePlanGddState, localProject?.projectPath]); - - useEffect(() => { - // 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个 - // 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时 - // 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次 - // hydrate,白烧 IPC。 - if ( - !localProject?.projectPath || - projectSupervisorRuntime?.status === undefined - ) { - return; - } - void hydratePlanGddState(localProject.projectPath); - }, [ - hydratePlanGddState, - localProject?.projectPath, - projectSupervisorRuntime?.phase, - projectSupervisorRuntime?.status, - projectSupervisorRuntime?.updatedAt, - ]); - - useEffect(() => { - const hydrateOnResume = () => { - if (document.visibilityState === 'hidden' || !localProject?.projectPath) { - return; - } - void hydratePlanGddState(localProject.projectPath); - }; - window.addEventListener('focus', hydrateOnResume); - document.addEventListener('visibilitychange', hydrateOnResume); - return () => { - window.removeEventListener('focus', hydrateOnResume); - document.removeEventListener('visibilitychange', hydrateOnResume); - }; - }, [hydratePlanGddState, localProject?.projectPath]); const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); @@ -1006,9 +1099,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); @@ -1018,6 +1111,7 @@ export function App({ const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); + const queueRunLocalShortcutRef = useRef<() => void>(() => undefined); function requestRuntimeConfigOpen() { if (projectSupervisorOnly && !supervisorChatOnly && !gameChatOnly) { @@ -1068,9 +1162,6 @@ export function App({ incoming, runtime, ); - if (gameChatOnly && !isGameChatSupervisorRoot(runtime)) { - nextStream = null; - } const candidateStream = nextStream; if (candidateStream?.status === 'ready' && gameChatOnly) { const text = candidateStream.accumulatedText.trim(); @@ -1151,20 +1242,7 @@ export function App({ return; } const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`; - const syncAlreadyStarted = - projectSupervisorRuntimeSyncingRef.current.has(syncKey); - if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { - // Freeze the old root and its currently known descendants before the - // asynchronous conversation refresh. A fast next turn may replace the - // current-by-agent map while that refresh is still in flight. - appendGameChatStageRecord( - projectPath, - runtime, - false, - !syncAlreadyStarted, - ); - } - if (syncAlreadyStarted) { + if (projectSupervisorRuntimeSyncingRef.current.has(syncKey)) { return; } const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -1172,124 +1250,57 @@ export function App({ return; } projectSupervisorRuntimeSyncingRef.current.add(syncKey); - if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { - const archiveKey = `${projectPath}\n${runtime.runId}`; - void invoke('get_local_game_manifest', { - projectPath, - }) - .then((capturedManifest) => { - const pendingArchive = - gameChatPendingStageRuntimesRef.current.get(archiveKey); - // The manifest has no root/run binding, so a read that resolves after - // the next round took over may already describe that newer round. - // Only freeze it while this root is still the current one; otherwise - // the pre-next-run capture gate supplies the snapshot instead. - if ( - pendingArchive && - projectSupervisorRuntimeRef.current?.runId === runtime.runId && - gameChatManifestHasTerminalPrimaryTask(capturedManifest) - ) { - pendingArchive.manifestSnapshot = capturedManifest; - flushPendingGameChatStageRecords(); - } - }) - .catch(() => { - // The normal manifest refresh path can still supply the snapshot. - }); - } void refreshConversation( invoke, projectPath, runtime.sessionId, runtime.runId, - ) - .then(() => { - const archiveKey = `${projectPath}\n${runtime.runId}`; - const pendingArchive = - gameChatPendingStageRuntimesRef.current.get(archiveKey); - if (pendingArchive) { - pendingArchive.awaitingConversationSync = false; - } - flushPendingGameChatStageRecords(); - }) - .catch((error) => { - projectSupervisorRuntimeSyncingRef.current.delete(syncKey); - if ( - localProjectPathRef.current === projectPath && - projectSupervisorSessionIdRef.current === runtime.sessionId - ) { - setProjectSupervisorRuntimeError( - `项目总控 Agent 对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }); + ).catch((error) => { + projectSupervisorRuntimeSyncingRef.current.delete(syncKey); + if ( + localProjectPathRef.current === projectPath && + projectSupervisorSessionIdRef.current === runtime.sessionId + ) { + setProjectSupervisorRuntimeError( + `项目总控 Agent 对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); } function flushPendingGameChatStageRecords() { - if (!gameChatOnly) { + if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { return; } for (const [ archiveKey, - pendingArchive, + pendingRuntime, ] of gameChatPendingStageRuntimesRef.current) { if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { gameChatPendingStageRuntimesRef.current.delete(archiveKey); continue; } - if (pendingArchive.awaitingConversationSync) { - continue; - } - pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots( - pendingArchive.runtimeRecords, - Object.values(agentRuntimeByIdRef.current), - ); - if ( - projectSupervisorRuntimeRef.current?.runId === - pendingArchive.rootRuntime.runId && - gameChatManifestHasTerminalPrimaryTask(manifest) - ) { - pendingArchive.manifestSnapshot = manifest; - } - const lineage = projectCurrentGameChatRuntimeLineage( - pendingArchive.rootRuntime, - pendingArchive.runtimeRecords, - ); - if (!canArchiveGameChatStage(lineage, pendingArchive.manifestSnapshot)) { - continue; - } - const runtimeSnapshot = Object.fromEntries( - pendingArchive.runtimeRecords.map((runtime) => [ - gameChatRuntimeIdentity(runtime), - runtime, - ]), - ); const progress = buildGameChatProgressEvidence( - pendingArchive.rootRuntime, - runtimeSnapshot, - pendingArchive.manifestSnapshot, + pendingRuntime, + agentRuntimeById, + manifest, ); if (!progress) { continue; } const text = formatGameChatStageRecord( - pendingArchive.rootRuntime, + pendingRuntime, progress, - collectGameChatResultImages(pendingArchive.manifestSnapshot), - ); - const messageId = gameChatStageRecordMessageId( - pendingArchive.rootRuntime.runId, + collectGameChatResultImages(manifest), ); gameChatArchivedRunKeysRef.current.add(archiveKey); gameChatPendingStageRuntimesRef.current.delete(archiveKey); setMessages((current) => { if ( current.some( - (message) => - message.role === 'assistant' && - (message.messageId === messageId || message.text === text), + (message) => message.role === 'assistant' && message.text === text, ) ) { return current; @@ -1304,17 +1315,7 @@ export function App({ current.length, ); } - const nextMessages: ChatMessage[] = [ - ...current, - { - role: 'assistant', - text, - messageId, - updatedAt: pendingArchive.rootRuntime.updatedAt, - }, - ]; - latestMessagesRef.current = nextMessages; - return nextMessages; + return [...current, { role: 'assistant', text }]; }); } } @@ -1323,12 +1324,9 @@ export function App({ nextProjectPath: string, runtime: AgentRuntimeState, ) { - if (!isGameChatSupervisorRoot(runtime)) { - return; - } const eventMessages = gameChatRuntimeEventMessages( runtime, - agentRuntimeByIdRef.current, + agentRuntimeById, ); if (eventMessages.length === 0) { return; @@ -1361,36 +1359,27 @@ export function App({ if (!gameChatOnly || runtimeResults.length === 0) { return; } - const runtimeStates = runtimeResults.map((result) => - agentRuntimeStateFromResult(result), - ); - const root = - projectSupervisorRuntimeRef.current ?? - runtimeStates.find( - (candidate) => candidate.agentId === PROJECT_SUPERVISOR_AGENT_ID, - ) ?? - null; - const lineage = projectCurrentGameChatRuntimeLineage(root, [ - ...Object.values(agentRuntimeByIdRef.current), - ...runtimeStates, - ]); - if (!lineage?.main) { + // A professional Runtime is only part of the active game-chat turn when + // it was delegated by the current Project Supervisor run. This prevents + // a stale child Runtime (or a different app mode) from leaking into the + // project transcript after a restart. + const supervisorRunId = + projectSupervisorRuntimeRef.current?.runId ?? + runtimeResults.find( + (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, + )?.state.runId; + if (!supervisorRunId) { return; } - const messages = runtimeResults.flatMap((result, index) => { - const runtime = runtimeStates[index]!; - const stream = result.responseStream; + const messages = runtimeResults.flatMap((result) => { + const runtime = agentRuntimeStateFromResult(result); if ( - !gameChatRuntimeBelongsToLineage(lineage, runtime) || - !stream || - stream.agentId !== runtime.agentId || - stream.taskId !== runtime.taskId || - stream.sessionId !== runtime.sessionId || - stream.runId !== runtime.runId + runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + runtime.parentRunId !== supervisorRunId ) { return []; } - return gameChatFinalReplyMessages([stream]); + return gameChatFinalReplyMessages([result.responseStream]); }); if (messages.length === 0) { return; @@ -1428,14 +1417,8 @@ export function App({ function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, - flush = true, - awaitingConversationSync = false, ) { - if ( - !gameChatOnly || - !isGameChatSupervisorRoot(runtime) || - !gameChatRuntimeHasTerminalOutcome(runtime) - ) { + if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; @@ -1445,27 +1428,8 @@ export function App({ if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - const previous = gameChatPendingStageRuntimesRef.current.get(archiveKey); - gameChatPendingStageRuntimesRef.current.set(archiveKey, { - rootRuntime: - !previous || runtime.updatedAt >= previous.rootRuntime.updatedAt - ? runtime - : previous.rootRuntime, - runtimeRecords: mergeGameChatRuntimeSnapshots( - previous?.runtimeRecords ?? [], - Object.values(agentRuntimeByIdRef.current), - ), - manifestSnapshot: - gameChatManifestHasTerminalPrimaryTask(manifest) && - projectSupervisorRuntimeRef.current?.runId === runtime.runId - ? manifest - : (previous?.manifestSnapshot ?? null), - awaitingConversationSync: - awaitingConversationSync || previous?.awaitingConversationSync === true, - }); - if (flush) { - flushPendingGameChatStageRecords(); - } + gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); + flushPendingGameChatStageRecords(); } useEffect(() => { @@ -1532,6 +1496,26 @@ export function App({ } initialProjectOpenedRef.current = true; if (projectSupervisorOnly) { + if (directCodexProductRuntime) { + if (!isAbsoluteProjectPath(initialProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); + return; + } + if (projectPathHasControlCharacter(initialProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); + return; + } + // This component is mounted inside the already-created project + // workbench. Hydrate localProject before the first direct turn so the + // direct runtime cannot silently create a second workspace. + void openWorkspace( + initialProjectPath, + false, + 'open', + initialProjectKind, + ); + return; + } if (gameChatOnly) { void openGameChatProjectPath(initialProjectPath); return; @@ -1563,7 +1547,7 @@ export function App({ }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); useEffect(() => { - if (projectSupervisorOnly) { + if (projectSupervisorOnly && !directCodexProductRuntime) { return; } const listen = window.__TAURI__?.event?.listen; @@ -1576,6 +1560,10 @@ export function App({ if (event.payload.projectPath !== localProjectPathRef.current) { return; } + if (directCodexProductRuntime) { + setDirectCodexProgress(event.payload.message); + return; + } setMessages((current) => [ ...current, { role: 'assistant', text: event.payload.message }, @@ -1602,7 +1590,7 @@ export function App({ disposed = true; cleanup?.(); }; - }, [projectSupervisorOnly]); + }, [directCodexProductRuntime, projectSupervisorOnly]); useEffect(() => { const listen = window.__TAURI__?.event?.listen; @@ -1708,10 +1696,6 @@ export function App({ disposed = true; cleanup?.(); }; - // The subscription deliberately invokes the current render's helper; its - // mutable state is read through refs, so resubscribing for that helper is - // neither necessary nor safe. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ appendGameChatFinalReplyMessages, gameChatOnly, @@ -2027,10 +2011,8 @@ export function App({ } const currentSupervisor = projectSupervisorRuntimeRef.current; let playableRevision = latestGameChatPlayableRevision( - projectCurrentGameChatRuntimeLineage( - currentSupervisor, - agentRuntimeByIdRef.current, - ), + currentSupervisor, + agentRuntimeByIdRef.current, ); if (playableRevision) { const currentRevision = await readCurrentProjectRevision(); @@ -2294,7 +2276,8 @@ export function App({ projectSupervisorOnly && pendingMessages.every( (message) => - message.runtimeOwned || + (message.runtimeOwned && + !isPersistableDirectCodexConversationMessage(message)) || isTransientProjectOpenMessage(message, nextProjectPath), ) ) { @@ -2357,7 +2340,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; } @@ -2890,6 +2876,31 @@ export function App({ { role: 'assistant', text: '准备运行当前本地游戏。' }, ]); } + queueRunLocalShortcutRef.current = queueRunLocalShortcut; + + 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); + queueRunLocalShortcutRef.current(); + }, [ + localProject?.projectPath, + onPlayRequestHandled, + playRequest, + projectSupervisorOnly, + ]); function queueStaticSmokeShortcut() { if (!requireChatProjectForUserAction()) { @@ -3161,14 +3172,42 @@ export function App({ const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { - const resumeError = await resumeProjectSupervisorRuntimeTasksIfNeeded( - invoke, - nextProjectPath, - ); - const sessionId = await readProjectSupervisorActiveSession( + const resumeError = directCodexProductRuntime + ? '' + : await resumeProjectSupervisorRuntimeTasksIfNeeded( + invoke, + nextProjectPath, + ); + if (directCodexProductRuntime) { + projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + } + let sessionId = await readProjectSupervisorActiveSession( invoke, nextProjectPath, ); + let persistedSupervisorRuntimeResult: AgentRuntimeResult | null = null; + let runtimeError = ''; + if (!sessionId && directCodexProductRuntime) { + try { + const runtimeResults = await invoke( + 'read_game_creator_agent_runtimes', + { projectPath: nextProjectPath }, + ); + persistedSupervisorRuntimeResult = + runtimeResults + .filter( + (runtimeResult) => + runtimeResult.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, + ) + .sort( + (left, right) => right.state.updatedAt - left.state.updatedAt, + )[0] ?? null; + sessionId = persistedSupervisorRuntimeResult?.state.sessionId || null; + } catch (error) { + runtimeError = error instanceof Error ? error.message : String(error); + } + } const projectConversation = await invoke( 'read_local_conversation', { @@ -3179,7 +3218,6 @@ export function App({ let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; - let runtimeError = ''; if (sessionId) { supervisorConversation = await invoke( 'read_local_conversation', @@ -3189,6 +3227,12 @@ export function App({ sessionId, }, ); + } + if (persistedSupervisorRuntimeResult) { + runtime = agentRuntimeStateFromResult(persistedSupervisorRuntimeResult); + runtimeResponseStream = + persistedSupervisorRuntimeResult.responseStream ?? null; + } else if (sessionId) { try { const runtimeResult = await invoke( 'read_game_creator_agent_runtime', @@ -3365,7 +3409,7 @@ export function App({ } : await invoke('init_local_game_project', { projectPath: trimmedProjectPath, - projectId: createLocalProjectId(), + projectId: seedManifest.projectId, name: gameChatOnly ? projectNameFromPath(trimmedProjectPath) : seedManifest.name, @@ -3433,6 +3477,9 @@ export function App({ }, ]); } + // Direct Codex must not resume legacy Supervisor work, but existing + // projects still need their conversation and run history hydrated as + // read-only evidence. void loadProjectConversation(openedProject.projectPath); void refreshAgentRunTrace(openedProject.projectPath); } catch (error) { @@ -3498,7 +3545,7 @@ export function App({ if (directoryStatus.isGodotProject) { await invoke('import_local_godot_project', { projectPath: trimmedProjectPath, - projectId: createLocalProjectId(), + projectId: seedManifest.projectId, name: projectNameFromPath(trimmedProjectPath), }); await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); @@ -5979,39 +6026,195 @@ export function App({ return sessionId; } - async function capturePendingGameChatStageManifestBeforeNextRun( + async function refreshDirectProjectSurface( invoke: TauriInvoke, - projectPath: string, + nextProjectPath: string, ) { - const archivePrefix = `${projectPath}\n`; - const pendingArchives = Array.from( - gameChatPendingStageRuntimesRef.current.entries(), - ).filter( - ([archiveKey, pendingArchive]) => - archiveKey.startsWith(archivePrefix) && - !pendingArchive.manifestSnapshot, - ); - for (const [, pendingArchive] of pendingArchives) { - const currentRoot = projectSupervisorRuntimeRef.current; - if ( - !isGameChatSupervisorRoot(currentRoot) || - currentRoot.runId !== pendingArchive.rootRuntime.runId - ) { - throw new Error('上一轮阶段清单尚未冻结,请稍后重试'); - } - const capturedManifest = await invoke( - 'get_local_game_manifest', - { projectPath }, - ); - if (!gameChatManifestHasTerminalPrimaryTask(capturedManifest)) { - throw new Error('上一轮主阶段仍在收束,请稍后重试'); - } - pendingArchive.manifestSnapshot = capturedManifest; + try { + await refreshManifest(nextProjectPath); + } catch { + // The direct turn result is still authoritative for the chat. A missing + // or legacy manifest must not hide the Codex reply. + } + try { + const current = await invoke( + 'get_local_game_preview_status', + { projectPath: nextProjectPath }, + ); + if (current.status === 'running' && current.url && current.port) { + updateClientPreview({ + url: current.url, + port: current.port, + root: current.root ?? nextProjectPath, + }); + setPreviewStatus(`运行中:127.0.0.1:${current.port}`); + return; + } + const started = await invoke( + 'start_local_game_preview', + { projectPath: nextProjectPath }, + ); + updateClientPreview(started); + setPreviewStatus(`运行中:127.0.0.1:${started.port}`); + setCommandLog((currentLog) => [...currentLog, 'preview.start']); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setPreviewStatus(`预览未启动:${message}`); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `智能创作已返回,但客户端预览启动失败:${message}`, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); } - flushPendingGameChatStageRecords(); } - 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) { + const directInvoke = resolveTauriInvoke(); + let directProjectPath = resolveChatProjectPath(localProject); + if (!directProjectPath && directInvoke) { + try { + const created = await directInvoke( + 'create_automatic_local_game_project', + ); + await openWorkspace(created.projectPath, false, 'open'); + directProjectPath = created.projectPath; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `自动创建 AGC 项目失败:${message}`, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + setProjectSupervisorRuntimeError(`智能创作执行失败:${message}`); + return; + } + } + 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(''); + try { + const reply = await directInvoke( + 'chat_with_game_creator_direct_codex', + { + projectPath: directProjectPath, + prompt, + }, + ); + if (localProjectPathRef.current === directProjectPath) { + setMessages((current) => [ + ...appendDirectUserMessageIfMissing(current), + { + role: 'assistant', + text: reply, + runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), + updatedAt: Date.now(), + }, + ]); + setDirectCodexProgress('项目已更新,正在刷新资源和运行预览'); + await refreshDirectProjectSurface(directInvoke, directProjectPath); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const visibleMessage = projectRuntimeVisibleError( + message, + '陶泥儿智能创作', + true, + ); + if (localProjectPathRef.current === directProjectPath) { + setProjectSupervisorRuntimeError(visibleMessage); + setMessages((current) => [ + ...appendDirectUserMessageIfMissing(current), + { + role: 'assistant', + text: visibleMessage, + runtimeOwned: true, + ...(directAssistantMessageId + ? { messageId: directAssistantMessageId } + : {}), + updatedAt: Date.now(), + }, + ]); + } + } finally { + setChatAgentBusy(false); + setDirectCodexProgress(''); + } + return; + } + const message = directInvoke + ? '当前项目尚未准备好,无法启动智能创作。' + : '需要在 Tauri App 内运行,无法启动智能创作。'; + setProjectSupervisorRuntimeError(message); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: message, + runtimeOwned: true, + ...(directConversationTurnId + ? { + messageId: directCodexConversationMessageId( + directConversationTurnId, + 'assistant', + ), + } + : {}), + updatedAt: Date.now(), + }, + ]); + return; + } if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -6039,57 +6242,31 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } + const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ + workspaceProjectKind, + orchestrationMode, + supervisorChatOnly, + gameChatOnly, + planningEntry: + !gameChatOnly && + workspaceProjectKind === 'web' && + projectSupervisorRuntimeRef.current?.source === + PROJECT_SUPERVISOR_PLAN_SOURCE, + }); const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - const planningEntry = - !gameChatOnly && - workspaceProjectKind === 'web' && - runtimeAtSubmission?.source === PROJECT_SUPERVISOR_PLAN_SOURCE; - const submissionRunProfile = planningEntry - ? 'standard' - : workspaceProjectKind === 'godot' || - (supervisorChatOnly && !gameChatOnly) - ? 'standard' - : 'autonomous-game-build'; - const submissionSource = planningEntry - ? PROJECT_SUPERVISOR_PLAN_SOURCE - : gameChatOnly - ? 'project-supervisor-game-chat' - : 'project-supervisor-gui'; - if ( - planningEntry && - runtimeAtSubmission && - runtimeAtSubmission.source === PROJECT_SUPERVISOR_PLAN_SOURCE && - !isAgentRuntimeTerminalState(runtimeAtSubmission) - ) { - setProjectSupervisorRuntimeError('请先完成当前立项策划步骤'); - return; - } - const steerRuntime = gameChatOnly - ? matchingAgentRuntimeForSteer( - [runtimeAtSubmission], - PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - submissionRunProfile, - 'project-supervisor-game-chat', - ) - : null; - if (gameChatOnly && !steerRuntime) { - await capturePendingGameChatStageManifestBeforeNextRun( - invoke, - nextProjectPath, - ); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - } + const steerRuntime = matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRoute.runProfile, + submissionRoute.source, + ); let autoPreviewAfterRevision = 0; let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { const playableAtSubmission = latestGameChatPlayableRevision( - projectCurrentGameChatRuntimeLineage( - runtimeAtSubmission, - agentRuntimeByIdRef.current, - ), + runtimeAtSubmission, + agentRuntimeByIdRef.current, ); autoPreviewAfterRevision = Math.max( gameChatPreviewRevisionRef.current ?? 0, @@ -6126,8 +6303,8 @@ export function App({ sessionId, prompt, runtime: runtimeAtSubmission, - runProfile: submissionRunProfile, - source: submissionSource, + runProfile: submissionRoute.runProfile, + source: submissionRoute.source, }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -6238,7 +6415,11 @@ export function App({ useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; - if (!gameChatOnly || !latch.prompt || !localProject) { + if ( + (!directCodexProductRuntime && !gameChatOnly) || + !latch.prompt || + !localProject + ) { return; } if (localProject.projectPath !== latch.projectPath) { @@ -6252,17 +6433,37 @@ 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); - }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); + void executeChatAgentReplyRef.current( + latch.prompt, + directConversationTurnId, + ); + }, [ + chatAgentBusy, + directCodexProductRuntime, + gameChatOnly, + initialSupervisorMessage, + localProject, + ]); async function handleProjectSupervisorToolAction( decision: 'confirm' | 'reject', @@ -10606,13 +10807,9 @@ export function App({ if (!runtime) { return; } - const next = mergeAgentRuntimeStateIntoMap( - agentRuntimeByIdRef.current, - runtime, - false, + setAgentRuntimeById((current) => + mergeAgentRuntimeStateIntoMap(current, runtime, false), ); - agentRuntimeByIdRef.current = next; - setAgentRuntimeById(next); } async function refreshAgentRuntimes( @@ -10625,6 +10822,9 @@ export function App({ return; } try { + if (directCodexProductRuntime) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + } if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { try { const resumedRuntimes = await invoke( @@ -11240,6 +11440,9 @@ export function App({ if (supervisorChatOnly || gameChatOnly) { supervisorChatShouldFollowLatestRef.current = true; } + const directConversationTurnId = directCodexProductRuntime + ? createDirectCodexConversationTurnId() + : undefined; setChatInput(''); setMessages((current) => [ ...current, @@ -11247,10 +11450,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( @@ -11261,59 +11472,66 @@ export function App({ if (projectSupervisorOnly && gameChatOnly) { const gameChatProjectPath = localProject?.projectPath ?? ''; - const hasCurrentGameChatRoot = isGameChatSupervisorRoot( - projectSupervisorRuntime, - ); return ( - setRuntimeConfigOpen(false)} - onConfirmConfirmation={confirmUiCommand} - onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} - onProjectPick={() => void handleGameChatProjectPick()} - onScroll={handleSupervisorChatScroll} - onShowEarlierMessages={showEarlierConversationMessages} - onSubmit={handleProjectSupervisorOnlySubmit} - onToolAction={handleProjectSupervisorToolAction} - onUserInput={handleProjectSupervisorUserInput} - pendingConfirmation={pendingUiConfirmation} - pendingCommand={pendingCommand} - onCancelPendingCommand={handlePendingCommandCancel} - onConfirmPendingCommand={() => void handlePendingCommandConfirm()} - pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} - onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} - onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} - preview={preview} - previewRevision={gameChatPreviewRevision} - previewStatus={previewStatus} - projectPath={gameChatProjectPath} - projectReady={Boolean(localProject)} - projectSelectionBusy={gameChatProjectSelectionBusy} - runtime={projectSupervisorRuntime} - runtimeByAgentId={agentRuntimeById} - manifest={manifest} - runtimeConfigOpen={runtimeConfigOpen} - runtimeError={projectSupervisorRuntimeError} - transientReply={ - hasCurrentGameChatRoot ? projectSupervisorTransientReply : '' - } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} - hasConversationControls={ - hasCurrentGameChatRoot && projectSupervisorHasConversationControls - } - hiddenConversationCount={hiddenConversationCount} - needsUserInput={ - hasCurrentGameChatRoot && projectSupervisorNeedsUserInput - } - visibleMessages={visibleMessages} - workspaceStatus={workspaceStatus} - expectedRunId={projectSupervisorExpectedRunId} - /> + <> + setRuntimeConfigOpen(false)} + onConfirmConfirmation={confirmUiCommand} + onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} + onProjectPick={() => void handleGameChatProjectPick()} + onScroll={handleSupervisorChatScroll} + onShowEarlierMessages={showEarlierConversationMessages} + onSubmit={handleProjectSupervisorOnlySubmit} + onToolAction={handleProjectSupervisorToolAction} + onUserInput={handleProjectSupervisorUserInput} + pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} + pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} + onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} + onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} + preview={preview} + previewRevision={gameChatPreviewRevision} + previewStatus={previewStatus} + projectPath={gameChatProjectPath} + projectReady={Boolean(localProject)} + projectSelectionBusy={gameChatProjectSelectionBusy} + runtime={projectSupervisorRuntime} + runtimeByAgentId={agentRuntimeById} + manifest={manifest} + runtimeConfigOpen={ + allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen + } + runtimeError={projectSupervisorRuntimeError} + transientReply={ + directCodexProductRuntime + ? directCodexProgress + : projectSupervisorTransientReply + } + transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + hasConversationControls={projectSupervisorHasConversationControls} + hiddenConversationCount={hiddenConversationCount} + needsUserInput={projectSupervisorNeedsUserInput} + visibleMessages={visibleMessages} + workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} + /> + {runtimeConfigOpen && allowAdvancedExternalEditorConfig ? ( + setRuntimeConfigOpen(false)} + onLog={(entry) => setCommandLog((current) => [...current, entry])} + /> + ) : null} + ); } @@ -11343,7 +11561,11 @@ export function App({ runtime={projectSupervisorRuntime} runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} - transientReply={projectSupervisorTransientReply} + transientReply={ + directCodexProductRuntime + ? directCodexProgress + : projectSupervisorTransientReply + } transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -11359,18 +11581,32 @@ export function App({ return ( void handlePendingCommandConfirm()} onScroll={handleConversationScroll} onShowEarlierMessages={showEarlierConversationMessages} onSubmit={handleProjectSupervisorOnlySubmit} - pendingConfirmation={pendingUiConfirmation} - transientReply={projectSupervisorTransientReply} + pendingConfirmation={ + directCodexProductRuntime ? null : pendingUiConfirmation + } + pendingCommand={directCodexProductRuntime ? pendingCommand : null} + projectPath={localProject?.projectPath ?? projectPath} + transientReply={ + directCodexProductRuntime + ? directCodexProgress + : projectSupervisorTransientReply + } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} + showProfessionalCollaboration={orchestrationMode === 'professional-dag'} workspaceStatus={workspaceStatus} planGddState={planGddState} planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} @@ -11381,6 +11617,7 @@ export function App({ error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} controlBusy={chatAgentBusy} + readOnly={directCodexProductRuntime} professionalResultsByAgentId={professionalAgentResultsById} onToolAction={handleProjectSupervisorToolAction} onSupervisorRetry={handleProjectSupervisorRetry} diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 74db6f576..459b16bf5 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -18,9 +18,23 @@ import { loginClientWithPhoneCode, logoutClientAuthSession, normalizeAuthPhoneInput, - refreshClientAuthAccessToken, sendClientPhoneLoginCode, } from '../services/clientAuth'; +import { + type ClientServerPreset, + type ClientServerSelection, + getClientServerSelection, + normalizeClientServerBaseUrl, + setClientServerSelection, +} from '../services/clientHttp'; +import { + beginPlatformSessionTransition, + clearCommittedPlatformSession, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + refreshPlatformSessionForGeneration, + subscribePlatformSessionRefresh, +} from '../services/platformSession'; type ClientRuntimeErrorBoundaryProps = { children: ReactNode; @@ -88,19 +102,67 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); + const initialServerSelection = getClientServerSelection(); + const [serverSelection, setServerSelection] = useState( + initialServerSelection, + ); + const [customServerUrl, setCustomServerUrl] = useState( + initialServerSelection.customBaseUrl, + ); + + function persistServerSelection() { + try { + setClientServerSelection({ + preset: serverSelection.preset, + customBaseUrl: customServerUrl, + }); + return true; + } catch (error) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + return false; + } + } + + function handleServerPresetChange(preset: ClientServerPreset) { + if (preset === 'custom') { + setServerSelection((current) => ({ ...current, preset })); + return; + } + const next = setClientServerSelection({ + preset, + customBaseUrl: customServerUrl, + }); + setServerSelection(next); + setLoginStatus(`已选择 ${preset} 服务器`); + } useEffect(() => { let disposed = false; async function hydrateAuth() { + const hydrationGeneration = currentPlatformSessionGeneration(); try { if (!getStoredAuthAccessToken()) { - await refreshClientAuthAccessToken(); + const refreshed = + await refreshPlatformSessionForGeneration(hydrationGeneration); + if (!refreshed) { + return; + } } const user = await getCurrentClientAuthUser(); if (disposed) { return; } if (user) { + const committedGeneration = await commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + ); + if (committedGeneration === null) { + return; + } + if (disposed) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); return; @@ -123,12 +185,27 @@ export function AuthenticatedClient({ } if (getStoredAuthAccessToken()) { try { - await refreshClientAuthAccessToken(); + const refreshed = + await refreshPlatformSessionForGeneration(hydrationGeneration); + if (!refreshed) { + return; + } const user = await getCurrentClientAuthUser(); if (disposed) { return; } if (user) { + const committedGeneration = + await commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + ); + if (committedGeneration === null) { + return; + } + if (disposed) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); return; @@ -159,6 +236,25 @@ export function AuthenticatedClient({ }; }, []); + useEffect( + () => + subscribePlatformSessionRefresh((result) => { + if (result.status === 'refreshed') { + setAuthUser((current) => + current?.id === result.user.id ? result.user : current, + ); + return; + } + if (result.status === 'failed') { + clearStoredAuthAccessToken(); + setAuthUser(null); + setAuthStatus('unauthenticated'); + setLoginStatus('登录已失效,请重新登录'); + } + }), + [], + ); + useEffect(() => { if (codeCooldownSeconds <= 0) { return; @@ -173,6 +269,9 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } + if (!persistServerSelection()) { + return; + } const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); @@ -209,13 +308,24 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } + if (!persistServerSelection()) { + return; + } setLoginBusy(true); setLoginStatus('正在登录'); + const loginGeneration = beginPlatformSessionTransition(); try { const user = loginMode === 'code' ? await loginClientWithPhoneCode(normalizedPhone, code) : await loginClientWithPassword(normalizedPhone, password); + const committedGeneration = await commitAuthenticatedPlatformSession( + user, + loginGeneration, + ); + if (committedGeneration === null) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); setCode(''); @@ -228,11 +338,13 @@ export function AuthenticatedClient({ } async function logout() { + const logoutGeneration = beginPlatformSessionTransition(); try { await logoutClientAuthSession(); } catch { clearStoredAuthAccessToken(); } + await clearCommittedPlatformSession(logoutGeneration); setAuthUser(null); setAuthStatus('unauthenticated'); setLoginStatus('已退出登录'); @@ -258,6 +370,48 @@ export function AuthenticatedClient({

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

+ + {serverSelection.preset === 'custom' ? ( + + ) : null}
+ +
+ ) : null} {pendingConfirmation ? (
@@ -147,7 +190,11 @@ export function ProjectSupervisorView({ disabled={runtimePanelProps.controlBusy || needsUserInput} rows={3} value={chatInput} - placeholder="告诉项目总控接下来要做什么" + placeholder={ + directCodex + ? '告诉陶泥儿接下来要做什么' + : '告诉项目总控接下来要做什么' + } onChange={(event) => onChatInputChange(event.currentTarget.value)} onKeyDown={(event) => { if ( @@ -178,40 +225,44 @@ export function ProjectSupervisorView({ {projectWorkspaceStatusForDisplay(workspaceStatus)}
- + {showProfessionalCollaboration ? ( + + ) : null} ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index bd0390f5e..13ff681c2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -298,7 +298,7 @@ export function ProjectWorkspaceChatPane({
-

AI 游戏创作

+

陶泥儿

{devMode ? '开发模式' : currentProjectTitle} run: {agentRunStatus} preview: {previewStatus} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index df6e2469e..67c08ccb5 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -24,7 +24,6 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import { type GameCreatorAgentLlmConfig, - type GameCreatorAgentMode, type GameCreatorAppConfig, type GameCreatorAppConfigView, type GameCreatorLlmApiKind, @@ -69,11 +68,11 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { agentMode: 'codex_app_server', llm: { apiKey: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4.1', + baseUrl: 'https://dev.genarrative.world/gpt/v1', + model: 'gpt-5.6-sol', apiKind: 'openai_responses', - reasoningEffort: 'high', - stream: false, + reasoningEffort: 'max', + stream: true, webSearchEnabled: false, contextWindowTokens: 128000, autoCompactTokenLimit: 64000, @@ -84,20 +83,19 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }, agentLlm: {}, editorApi: { - baseUrl: 'http://127.0.0.1:8082', + baseUrl: 'https://dev.genarrative.world', apiKey: '', }, mcpServers: {}, }; -const runtimeAgentModes = new Set([ - 'codex_app_server', - 'codex_cli', - 'provider', -]); - type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced'; +type RuntimeConfigToast = { + tone: 'success' | 'error'; + message: string; +}; + const runtimeSettingsSections = [ { id: 'general', @@ -501,6 +499,7 @@ function normalizeRuntimeMcpServerConfig( function normalizeRuntimeConfigDraft( config: GameCreatorAppConfig, + allowAdvancedExternalEditorConfig: boolean, ): GameCreatorAppConfig { const apiKind: GameCreatorLlmApiKind = [ 'openai_responses', @@ -564,21 +563,28 @@ function normalizeRuntimeConfigDraft( retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), }, agentLlm, + editorApi: allowAdvancedExternalEditorConfig + ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } + : { ...defaultRuntimeConfigDraft.editorApi }, mcpServers, }; } export function RuntimeConfigDialog({ projectPath, + allowAdvancedExternalEditorConfig = false, onClose, onLog, }: { projectPath?: string; + allowAdvancedExternalEditorConfig?: boolean; onClose: () => void; onLog?: (entry: string) => void; }) { const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); + const [runtimeConfigToast, setRuntimeConfigToast] = + useState(null); const [runtimeConfigDraft, setRuntimeConfigDraft] = useState(defaultRuntimeConfigDraft); const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); @@ -596,6 +602,18 @@ export function RuntimeConfigDialog({ useEscapeToClose(onClose); + useEffect(() => { + const htmlOverflow = document.documentElement.style.overflow; + const bodyOverflow = document.body.style.overflow; + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + + return () => { + document.documentElement.style.overflow = htmlOverflow; + document.body.style.overflow = bodyOverflow; + }; + }, []); + useEffect(() => { void readRuntimeConfig(); // The dialog reads once on mount; subsequent reads are explicit user actions. @@ -615,10 +633,6 @@ export function RuntimeConfigDialog({ })); } - function updateRuntimeAgentMode(agentMode: GameCreatorAgentMode) { - setRuntimeConfigDraft((current) => ({ ...current, agentMode })); - } - function updateRuntimeLlmProviderPreset( presetId: RuntimeLlmProviderPresetId, ) { @@ -694,18 +708,6 @@ export function RuntimeConfigDialog({ })); } - function updateRuntimeEditorConfig< - K extends keyof GameCreatorAppConfig['editorApi'], - >(key: K, value: GameCreatorAppConfig['editorApi'][K]) { - setRuntimeConfigDraft((current) => ({ - ...current, - editorApi: { - ...current.editorApi, - [key]: value, - }, - })); - } - function updateRuntimeMcpServer( serverId: string, key: K, @@ -801,7 +803,10 @@ export function RuntimeConfigDialog({ 'read_game_creator_app_config', ); setRuntimeConfigPath(result.path); - const config = normalizeRuntimeConfigDraft(result.config); + const config = normalizeRuntimeConfigDraft( + result.config, + allowAdvancedExternalEditorConfig, + ); setRuntimeConfigDraft(config); setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers)); setMcpCatalog(null); @@ -830,32 +835,50 @@ export function RuntimeConfigDialog({ runtimeConfigBusyRef.current = true; setRuntimeConfigBusy(true); + setRuntimeConfigToast(null); setRuntimeConfigStatus('正在保存'); try { - const config = normalizeRuntimeConfigDraft({ - ...runtimeConfigDraft, - mcpServers: materializeRuntimeMcpServers( - runtimeConfigDraft.mcpServers, - mcpStructuredDrafts, - ), - }); + const config = normalizeRuntimeConfigDraft( + { + ...runtimeConfigDraft, + agentMode: 'codex_app_server', + editorApi: allowAdvancedExternalEditorConfig + ? runtimeConfigDraft.editorApi + : defaultRuntimeConfigDraft.editorApi, + mcpServers: materializeRuntimeMcpServers( + runtimeConfigDraft.mcpServers, + mcpStructuredDrafts, + ), + }, + allowAdvancedExternalEditorConfig, + ); const result = await invoke( 'write_game_creator_app_config', { config }, ); setRuntimeConfigPath(result.path); - const savedConfig = normalizeRuntimeConfigDraft(result.config); + const savedConfig = normalizeRuntimeConfigDraft( + result.config, + allowAdvancedExternalEditorConfig, + ); setRuntimeConfigDraft(savedConfig); setMcpStructuredDrafts( runtimeMcpStructuredDrafts(savedConfig.mcpServers), ); setMcpCatalog(null); setRuntimeConfigStatus(`已保存:${result.path}`); + setRuntimeConfigToast({ + tone: 'success', + message: '保存成功,新的运行时配置已生效', + }); onLog?.('runtime_config.save'); } catch (error) { - setRuntimeConfigStatus( - error instanceof Error ? error.message : String(error), - ); + const message = error instanceof Error ? error.message : String(error); + setRuntimeConfigStatus(message); + setRuntimeConfigToast({ + tone: 'error', + message: `保存失败:${message}`, + }); } finally { runtimeConfigBusyRef.current = false; setRuntimeConfigBusy(false); @@ -933,6 +956,24 @@ export function RuntimeConfigDialog({ role="presentation" onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)} > + {runtimeConfigToast ? ( +
+ {runtimeConfigToast.tone === 'error' ? ( +
+ ) : null}
{activeSection === 'general' ? ( <> - +
+ Agent 模式 + 陶泥儿智能创作(固定) + 需求将由陶泥儿智能创作服务执行 +
{runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <>
) : null} - {activeSection === 'connections' ? ( - <> - - - - ) : null} {activeSection === 'connections' ? (
+ {allowAdvancedExternalEditorConfig ? ( +
+ + +
+ ) : null}

MCP servers

diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index b72f486e2..a4baa7eaa 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -38,6 +38,7 @@ const gameChatApp = ( ); diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 6ea68c419..1633b2937 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -52,6 +52,27 @@ export function clearStoredAuthAccessToken() { let clientAuthRefreshPromise: Promise | null = null; +const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + +function getClientAuthNetworkErrorMessage(error: unknown) { + const detail = + error instanceof Error ? error.message.trim() : String(error).trim(); + if (/timed? ?out|timeout|超时/iu.test(detail)) { + return '无法连接登录服务:连接超时,请检查服务器地址和网络后重试'; + } + if (/econnrefused|connection refused|拒绝连接/iu.test(detail)) { + return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口'; + } + if (/dns|resolve|name or service not known|无法解析/iu.test(detail)) { + return '无法连接登录服务:服务器地址无法解析,请检查服务器选择'; + } + if (/certificate|tls|ssl|证书/iu.test(detail)) { + return '无法连接登录服务:安全连接失败,请检查服务器地址和证书'; + } + return CLIENT_AUTH_NETWORK_ERROR_MESSAGE; +} + class ClientAuthRequestError extends Error { readonly status: number | null; readonly networkError: boolean; @@ -122,11 +143,10 @@ async function requestAuthJson( credentials: 'same-origin', headers, }); - } catch { - throw new ClientAuthRequestError( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - { networkError: true }, - ); + } catch (error) { + throw new ClientAuthRequestError(getClientAuthNetworkErrorMessage(error), { + networkError: true, + }); } if (!response.ok) { throw new ClientAuthRequestError( diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index 8a6305180..619299276 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -1,11 +1,123 @@ import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; +export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world'; + +export type ClientServerPreset = 'release' | 'dev' | 'custom'; + +export type ClientServerSelection = { + preset: ClientServerPreset; + customBaseUrl: string; +}; + +const CLIENT_SERVER_SELECTION_STORAGE_KEY = + 'genarrative.client.server-selection.v1'; + +function defaultClientServerPreset(): Exclude { + return import.meta.env.DEV ? 'dev' : 'release'; +} + +function isClientServerPreset(value: unknown): value is ClientServerPreset { + return value === 'release' || value === 'dev' || value === 'custom'; +} + +export function normalizeClientServerBaseUrl(value: string) { + const normalized = value.trim().replace(/\/+$/u, ''); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('服务器地址无效'); + } + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + throw new Error('服务器地址必须是纯 HTTP(S) 地址'); + } + const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes( + parsed.hostname, + ); + if (parsed.protocol === 'http:' && !isLoopback) { + throw new Error('非本机服务器必须使用 HTTPS'); + } + return normalized; +} + +function readStoredClientServerSelection(): ClientServerSelection { + const fallback: ClientServerSelection = { + preset: defaultClientServerPreset(), + customBaseUrl: '', + }; + if (typeof window === 'undefined') return fallback; + try { + const raw = window.localStorage.getItem( + CLIENT_SERVER_SELECTION_STORAGE_KEY, + ); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as { + preset?: unknown; + customBaseUrl?: unknown; + }; + if (!isClientServerPreset(parsed.preset)) return fallback; + const customBaseUrl = + typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : ''; + if (parsed.preset === 'custom') { + normalizeClientServerBaseUrl(customBaseUrl); + } + return { preset: parsed.preset, customBaseUrl }; + } catch { + return fallback; + } +} + +export function getClientServerSelection() { + return readStoredClientServerSelection(); +} + +export function setClientServerSelection( + selection: ClientServerSelection, +): ClientServerSelection { + const next: ClientServerSelection = { + preset: selection.preset, + customBaseUrl: + selection.preset === 'custom' + ? normalizeClientServerBaseUrl(selection.customBaseUrl) + : selection.customBaseUrl.trim(), + }; + if (typeof window !== 'undefined') { + window.localStorage.setItem( + CLIENT_SERVER_SELECTION_STORAGE_KEY, + JSON.stringify(next), + ); + } + return next; +} + +export function resetClientServerSelectionForTests() { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY); + } +} + +export function getClientServerBaseUrl( + selection: ClientServerSelection = getClientServerSelection(), +) { + if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL; + if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL; + return normalizeClientServerBaseUrl(selection.customBaseUrl); +} type ClientHttpContext = { isDevelopment: boolean; isTauri: boolean; pageProtocol: string; + mode?: string; + serverBaseUrl?: string; }; type ClientHttpTarget = { @@ -18,6 +130,7 @@ function currentClientHttpContext(): ClientHttpContext { isDevelopment: import.meta.env.DEV, isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__), pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol, + mode: import.meta.env.MODE, }; } @@ -25,23 +138,24 @@ export function resolveClientHttpTarget( url: string, context: ClientHttpContext = currentClientHttpContext(), ): ClientHttpTarget { - if (context.isDevelopment) { + // Existing unit fixtures omit mode; retain the Vite-relative transport for + // them while real development/release clients use the selected server. + if ( + !context.serverBaseUrl && + (context.mode === 'test' || (!context.mode && context.isDevelopment)) + ) { return { transport: 'web', url }; } - const isHttpPage = - context.pageProtocol === 'http:' || context.pageProtocol === 'https:'; - if (!context.isTauri && isHttpPage) { - return { transport: 'web', url }; + const serverBaseUrl = + context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection()); + const target = new URL(url, `${serverBaseUrl}/`); + if (target.origin !== serverBaseUrl) { + throw new Error('请求目标不在当前选择的服务器范围内'); } if (!context.isTauri) { - return { transport: 'web', url }; - } - - const target = new URL(url, `${AGC_DEVELOPMENT_API_BASE_URL}/`); - if (target.origin !== AGC_DEVELOPMENT_API_BASE_URL) { - throw new Error('AGC release API 请求目标不在允许的开发服务器范围内'); + return { transport: 'web', url: target.toString() }; } return { transport: 'tauri-http', url: target.toString() }; } diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts new file mode 100644 index 000000000..754e0f38c --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -0,0 +1,196 @@ +import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; +import { resolveTauriInvoke } from '../app/tauri'; +import { + getCurrentClientAuthUser, + getStoredAuthAccessToken, + refreshClientAuthAccessToken, +} from './clientAuth'; +import { getClientServerBaseUrl } from './clientHttp'; + +const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; + +type CommittedPlatformSession = { + user: AuthUser; + accessToken: string; + apiBaseUrl: string; + generation: number; +}; + +export type PlatformSessionRefreshResult = + | { status: 'refreshed'; user: AuthUser; generation: number } + | { status: 'stale' } + | { status: 'failed'; error: unknown }; + +type PlatformSessionRefreshListener = ( + result: PlatformSessionRefreshResult, +) => void; + +let platformAuthGeneration = 0; +let committedPlatformSession: CommittedPlatformSession | null = null; +let platformSessionRefreshPromise: Promise | null = + null; +const platformSessionRefreshListeners = + new Set(); + +function restoreCommittedAccessToken() { + if (committedPlatformSession?.accessToken) { + window.localStorage.setItem( + ACCESS_TOKEN_STORAGE_KEY, + committedPlatformSession.accessToken, + ); + return; + } + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); +} + +function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) { + for (const listener of platformSessionRefreshListeners) { + listener(result); + } +} + +async function installCommittedPlatformSession( + session: CommittedPlatformSession, +) { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + await invoke('install_platform_account_session', { + userId: session.user.id, + accessToken: session.accessToken, + apiBaseUrl: session.apiBaseUrl, + generation: session.generation, + }); +} + +async function resolvePlatformApiBaseUrl() { + return getClientServerBaseUrl(); +} + +async function commitPlatformSession( + user: AuthUser, + expectedGeneration: number, +): Promise { + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + if (platformAuthGeneration !== expectedGeneration) { + if ( + committedPlatformSession?.user.id === user.id && + committedPlatformSession.accessToken === accessToken + ) { + return committedPlatformSession; + } + restoreCommittedAccessToken(); + return null; + } + platformAuthGeneration += 1; + const apiBaseUrl = await resolvePlatformApiBaseUrl(); + committedPlatformSession = { + user, + accessToken, + apiBaseUrl, + generation: platformAuthGeneration, + }; + return committedPlatformSession; +} + +export function currentPlatformSessionGeneration() { + return platformAuthGeneration; +} + +export function beginPlatformSessionTransition() { + platformAuthGeneration += 1; + committedPlatformSession = null; + return platformAuthGeneration; +} + +export async function commitAuthenticatedPlatformSession( + user: AuthUser, + expectedGeneration: number, +) { + const session = await commitPlatformSession(user, expectedGeneration); + if (!session) return null; + await installCommittedPlatformSession(session); + return session.generation; +} + +export async function refreshPlatformSessionForGeneration( + expectedGeneration: number, +) { + const token = await refreshClientAuthAccessToken(); + if (platformAuthGeneration !== expectedGeneration) { + restoreCommittedAccessToken(); + return null; + } + return token; +} + +export function requestPlatformSessionRefresh(expectedUserId?: string) { + if (platformSessionRefreshPromise) return platformSessionRefreshPromise; + + const expectedGeneration = platformAuthGeneration; + const expectedSessionUserId = + expectedUserId?.trim() || committedPlatformSession?.user.id || ''; + platformSessionRefreshPromise = + (async (): Promise => { + try { + const refreshed = + await refreshPlatformSessionForGeneration(expectedGeneration); + if (!refreshed) return { status: 'stale' }; + const user = await getCurrentClientAuthUser(); + if ( + platformAuthGeneration !== expectedGeneration || + !user || + (expectedSessionUserId && user.id !== expectedSessionUserId) + ) { + restoreCommittedAccessToken(); + return { status: 'stale' }; + } + const session = await commitPlatformSession(user, expectedGeneration); + if (!session) return { status: 'stale' }; + await installCommittedPlatformSession(session); + return { + status: 'refreshed', + user, + generation: session.generation, + }; + } catch (error) { + if (platformAuthGeneration === expectedGeneration) { + const clearGeneration = beginPlatformSessionTransition(); + await clearCommittedPlatformSession(clearGeneration); + } + return { status: 'failed', error }; + } + })().then((result) => { + notifyPlatformSessionRefresh(result); + return result; + }); + platformSessionRefreshPromise.finally(() => { + platformSessionRefreshPromise = null; + }); + return platformSessionRefreshPromise; +} + +export function subscribePlatformSessionRefresh( + listener: PlatformSessionRefreshListener, +) { + platformSessionRefreshListeners.add(listener); + return () => { + platformSessionRefreshListeners.delete(listener); + }; +} + +export async function clearCommittedPlatformSession(generation: number) { + restoreCommittedAccessToken(); + const invoke = resolveTauriInvoke(); + if (!invoke) return; + await invoke('clear_platform_account_session', { generation }); +} + +export function resetPlatformSessionStateForTests() { + platformAuthGeneration = 0; + committedPlatformSession = null; + platformSessionRefreshPromise = null; + platformSessionRefreshListeners.clear(); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index f6ba7614a..7f139ca60 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -86,6 +86,22 @@ textarea { font: inherit; } +.client-auth-panel select { + height: 38px; + min-width: 0; + padding: 0 11px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #111827; + font: inherit; +} + +.client-auth-panel select:focus { + border-color: #111827; + outline: 2px solid rgb(17 24 39 / 10%); +} + .client-auth-panel input:focus { border-color: #111827; outline: 2px solid rgb(17 24 39 / 10%); @@ -3596,6 +3612,36 @@ h2 { backdrop-filter: blur(6px); } +.runtime-settings-toast { + position: absolute; + top: 24px; + right: 24px; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: min(420px, calc(100vw - 48px)); + padding: 11px 14px; + border: 1px solid var(--platform-success-border); + border-radius: 12px; + background: #edf8ef; + box-shadow: var(--platform-panel-shadow); + color: var(--platform-success-text); + font-size: 12px; + font-weight: 700; + pointer-events: none; +} + +.runtime-settings-toast[data-tone='error'] { + border-color: var(--platform-warm-border); + background: #fff1ea; + color: var(--platform-warm-text); +} + +.runtime-settings-toast span { + overflow-wrap: anywhere; +} + .settings-panel { display: grid; gap: 10px; @@ -3608,6 +3654,11 @@ h2 { } .runtime-settings-panel { + --runtime-settings-fill: #fffdfa; + --runtime-settings-topbar-fill: #fffdf9; + --runtime-settings-nav-fill: #fdf7f1; + --runtime-settings-content-fill: #fffaf6; + --runtime-settings-subpanel-fill: #fffdfb; grid-template-rows: auto minmax(0, 1fr) auto; gap: 0; width: min(1080px, 100%); @@ -3617,7 +3668,7 @@ h2 { overflow: hidden; border-color: var(--platform-modal-border); border-radius: 18px; - background: var(--platform-modal-fill); + background: var(--runtime-settings-fill); box-shadow: var(--platform-panel-shadow); } @@ -3634,7 +3685,7 @@ h2 { min-height: 76px; padding: 15px 18px 14px 22px; border-bottom: 1px solid var(--platform-line-soft); - background: var(--platform-desktop-topbar-fill); + background: var(--runtime-settings-topbar-fill); } .runtime-settings-header > div { @@ -3685,7 +3736,7 @@ h2 { padding: 16px 12px; overflow-y: auto; border-right: 1px solid var(--platform-line-soft); - background: var(--platform-nav-fill); + background: var(--runtime-settings-nav-fill); } .runtime-settings-nav nav { @@ -3777,8 +3828,7 @@ h2 { min-height: 0; padding: 22px 24px 28px; overflow-y: auto; - background: var(--platform-desktop-panel-fill); - scrollbar-gutter: stable; + background: var(--runtime-settings-content-fill); } .runtime-settings-section-header { @@ -3815,7 +3865,7 @@ h2 { padding: 13px; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: var(--platform-subpanel-fill); + background: var(--runtime-settings-subpanel-fill); color: var(--platform-text-base); } @@ -3851,7 +3901,7 @@ h2 { overflow: hidden; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: var(--platform-subpanel-fill); + background: var(--runtime-settings-subpanel-fill); } .runtime-agent-card-summary { @@ -3920,7 +3970,7 @@ h2 { min-height: 66px; padding: 11px 18px 11px 22px; border-top: 1px solid var(--platform-line-soft); - background: var(--platform-desktop-topbar-fill); + background: var(--runtime-settings-topbar-fill); } .runtime-settings-footer .status-line { @@ -3941,7 +3991,7 @@ h2 { .runtime-settings-footer .status-line[data-tone='success'] { border-color: var(--platform-success-border); - background: var(--platform-success-fill); + background: #edf8ef; color: var(--platform-success-text); } @@ -4841,6 +4891,13 @@ iframe.preview-frame { padding: 0; } + .runtime-settings-toast { + top: 12px; + right: 12px; + left: 12px; + max-width: none; + } + .runtime-settings-panel { width: 100%; height: 100dvh; @@ -4912,7 +4969,7 @@ iframe.preview-frame { max-width: none; min-height: 0; gap: 10px; - padding: 40px 8px 8px; + padding: 72px 8px 8px; overflow: hidden; color: var(--platform-text-base); } @@ -5035,6 +5092,20 @@ iframe.preview-frame { color: var(--platform-button-secondary-text); } +.game-workbench-view-actions .game-workbench-play-button { + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + color: var(--platform-button-primary-text); +} + +.game-workbench-view-actions .game-workbench-play-button:disabled { + border-color: var(--platform-surface-border); + background: var(--platform-button-secondary-fill); + color: var(--platform-text-muted); + cursor: not-allowed; + opacity: 0.6; +} + .game-resource-reorder-status { min-width: 0; color: #9a725f; @@ -5887,6 +5958,27 @@ iframe.preview-frame { user-select: text; } +.game-resource-code-body { + min-width: 0; + overflow: auto; + border: 1px solid #eadbd4; + border-radius: 12px; + background: #2c2021; + color: #ffe8d8; +} + +.game-resource-code-body pre { + min-width: max-content; + margin: 0; + padding: 14px; + font: + 12px/1.55 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + white-space: pre; +} + .game-resource-document-body > :first-child { margin-top: 0; } @@ -5992,8 +6084,10 @@ iframe.preview-frame { .game-run-surface { display: grid; - grid-template-rows: minmax(300px, 1fr) auto auto; + grid-template-rows: minmax(300px, 1fr) auto; + grid-row: 2 / -1; gap: 12px; + height: 100%; min-height: 0; padding: 12px; background: #fffdfa; @@ -6001,10 +6095,7 @@ iframe.preview-frame { .game-run-preview { position: relative; - display: grid; - align-content: center; - justify-items: center; - gap: 9px; + display: block; min-height: 300px; border: 1px dashed #dfb59f; border-radius: 14px; @@ -6015,9 +6106,11 @@ iframe.preview-frame { } .game-run-preview iframe { + position: absolute; + inset: 0; width: 100%; height: 100%; - min-height: 300px; + min-height: 0; border: 0; background: #fff; } @@ -6044,33 +6137,6 @@ iframe.preview-frame { overflow-wrap: anywhere; } -.game-run-slice-controls { - display: grid; - grid-template-columns: auto auto 1fr auto; - align-items: center; - gap: 8px; -} - -.game-run-slice-controls button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - min-height: 34px; - padding: 0 10px; - border: 1px solid #e7d3c9; - border-radius: 999px; - background: #fff; - color: #855c4c; - cursor: pointer; -} - -.game-run-slice-controls span { - color: #9a7d70; - font-size: 11px; - text-align: center; -} - .game-run-panels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -6839,15 +6905,6 @@ iframe.preview-frame { min-width: 460px; } - .game-run-slice-controls { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .game-run-slice-controls span { - grid-column: 1 / -1; - grid-row: 1; - } - .game-run-panels { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 2ccab3a72..dc8c7f632 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -46,7 +46,6 @@ type HomeViewProps = { onStatusChange: (status: string) => void; homeAgentModeItems: readonly HomeAgentModeItem[]; recentProjectRows: readonly HomeProjectRow[]; - onCreateDraft: (draft: HomeDraft) => Promise; onCreateDraftAutomatically: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; @@ -59,7 +58,6 @@ export default function HomeView({ onStatusChange, homeAgentModeItems, recentProjectRows, - onCreateDraft, onCreateDraftAutomatically, onProjectsOpen, onProjectOpen, @@ -118,7 +116,7 @@ export default function HomeView({ function handleHomeSubmit(event: FormEvent) { event.preventDefault(); - void createFromHome(onCreateDraft, '请选择项目目录'); + void createFromHome(onCreateDraftAutomatically, '正在创建工作区'); } return ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e196dd699..36a79c60a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1,7 +1,6 @@ import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core'; import { - ChevronLeft, - ChevronRight, + Code2, FileText, FolderTree, Gamepad2, @@ -61,6 +60,7 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { requestPlatformSessionRefresh } from '../../services/platformSession'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -117,6 +117,32 @@ type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; +type OrchestrationMode = 'single-supervisor' | 'professional-dag'; + +function isPlatformAuthenticationRequired(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('登录已失效') + ); +} + +async function withPlatformSessionRefresh(operation: () => Promise) { + try { + return await operation(); + } catch (error) { + if (!isPlatformAuthenticationRequired(error)) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status !== 'refreshed') { + throw new Error( + refresh.status === 'stale' + ? '登录账号已变化,原资源操作已停止' + : '登录已失效,请重新登录', + ); + } + return operation(); + } +} type WebKitGestureEvent = Event & { clientX?: number; @@ -234,6 +260,7 @@ const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; export type ProjectDevelopmentViewProps = { + orchestrationMode?: OrchestrationMode; projectName: string; projectPath: string; manifest: GameCreationAppManifest; @@ -246,6 +273,7 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onPlay?: () => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -254,6 +282,7 @@ export type ProjectDevelopmentViewProps = { }; const categoryOrder: ResourceCategory[] = [ + 'code', 'document', 'version', 'art', @@ -261,6 +290,7 @@ const categoryOrder: ResourceCategory[] = [ ]; const categoryLabels: Record = { + code: '游戏代码', document: '文档', version: '项目版本', art: '美术资源', @@ -268,6 +298,7 @@ const categoryLabels: Record = { }; const categoryIcons: Record = { + code: Code2, document: FileText, version: Gamepad2, art: Image, @@ -591,7 +622,7 @@ const ResourceCard = memo(function ResourceCard({ ); } - if (kind === 'document' && documentSummary) { + if ((kind === 'document' || kind === 'code') && documentSummary) { return ( {documentSummary} @@ -670,6 +701,7 @@ const ResourceCard = memo(function ResourceCard({ }); export default function ProjectDevelopmentView({ + orchestrationMode = 'professional-dag', projectName, projectPath, manifest, @@ -679,7 +711,9 @@ export default function ProjectDevelopmentView({ agentResults = emptyProjectAgentResults, supervisor, onManifestChange, + onPlay, }: ProjectDevelopmentViewProps) { + const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); @@ -720,8 +754,6 @@ export default function ProjectDevelopmentView({ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); const [showAllAgentGroups, setShowAllAgentGroups] = useState(false); - const [runPlaying, setRunPlaying] = useState(false); - const [activeSlice, setActiveSlice] = useState(0); const [mediaDuration, setMediaDuration] = useState(null); const [focusedAudioPlayIdentity, setFocusedAudioPlayIdentity] = useState< string | null @@ -1021,6 +1053,7 @@ export default function ProjectDevelopmentView({ mode: sortMode, resources, canvasRef: resourceCanvasRef, + eagerPreviewLimit: 12, }); const resourceSectionHeights = useProjectResourceSectionHeights({ projectId: manifest.projectId, @@ -2294,15 +2327,17 @@ export default function ProjectDevelopmentView({ setPendingResourceEditsError(''); setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`); try { - const result = await invoke( - 'resume_local_project_resource_edit', - { - input: { - projectPath: actionProject.projectPath, - expectedProjectId: actionProject.projectId, - operationId: pending.operationId, + const result = await withPlatformSessionRefresh(() => + invoke( + 'resume_local_project_resource_edit', + { + input: { + projectPath: actionProject.projectPath, + expectedProjectId: actionProject.projectId, + operationId: pending.operationId, + }, }, - }, + ), ); if (!isCurrentRecoveryProject(actionProject)) return; if (result.manifest.projectId !== actionProject.projectId) { @@ -2599,30 +2634,32 @@ export default function ProjectDevelopmentView({ ); } const resource = route.resource; - const result = await invoke( - 'derive_local_project_resource', - { - input: { - projectPath, - expectedProjectId: manifest.projectId, - expectedProjectRevision, - operationId: route.operationId, - idempotencyKey: route.idempotencyKey, - editKind: route.capability.editKind, - sourceResourceId: resource.id, - sourceAssetId: resource.manifestAssetId, - sourcePath: - resource.version || resource.subtype === 'agent-result' - ? null - : resource.path, - sourceMediaType: route.capability.sourceMediaType, - sourceSubtype: resource.subtype, - producerTaskId: resource.producerTaskId, - sourceVersionId: resource.version?.versionId ?? null, - prompt, - assetName, + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision, + operationId: route.operationId, + idempotencyKey: route.idempotencyKey, + editKind: route.capability.editKind, + sourceResourceId: resource.id, + sourceAssetId: resource.manifestAssetId, + sourcePath: + resource.version || resource.subtype === 'agent-result' + ? null + : resource.path, + sourceMediaType: route.capability.sourceMediaType, + sourceSubtype: resource.subtype, + producerTaskId: resource.producerTaskId, + sourceVersionId: resource.version?.versionId ?? null, + prompt, + assetName, + }, }, - }, + ), ); if ( result.manifest.projectId !== manifest.projectId || @@ -2960,6 +2997,21 @@ export default function ProjectDevelopmentView({
+ {mode === 'resources' && !focusedResource && !assetCanvasRoute && @@ -3298,6 +3350,22 @@ export default function ProjectDevelopmentView({ )}
) : null} + {focusedResource.category === 'code' ? ( +
+ {focusedResourceCardPreview.status === 'loaded' && + focusedResourceCardPreview.preview.content !== undefined ? ( +
+                        
+                          {focusedResourceCardPreview.preview.content}
+                        
+                      
+ ) : focusedResourceCardPreview.status === 'failed' ? ( +

{focusedResourceCardPreview.error}

+ ) : ( +

正在载入游戏代码…

+ )} +
+ ) : null}
) : mode === 'resources' ? ( @@ -3685,45 +3753,10 @@ export default function ProjectDevelopmentView({
)} -
- - - {`测试切片 ${activeSlice + 1}`} - -
@@ -3779,71 +3812,85 @@ export default function ProjectDevelopmentView({
与陶泥儿的对话 - 项目总控 Agent + + {professionalDagVisible ? '项目总控 Agent' : '智能创作'} +
- + {professionalDagVisible ? ( + + ) : ( + + 自动执行 + + )}
{supervisor}
-
- {agentSummaries.map((agent) => ( -
- - { - { - design: '设', - art: '美', - code: '程', - balance: '数', - audio: '音', - publishing: '发', - }[agent.group] - } - - - {agent.label} - {agent.failureSummary ?? agent.statusLabel} - - + {agentSummaries.map((agent) => ( +
- {agent.currentTask} - {agent.currentAction ? ( - {agent.currentAction} - ) : null} - {agent.waitingOn ? ( - {`等待:${agent.waitingOn}`} - ) : null} - {`任务完成 ${agent.completedCount}/${agent.totalCount}`} - -
- ))} - -
+ + { + { + design: '策', + art: '美', + code: '程', + balance: '数', + audio: '音', + publishing: '发', + }[agent.group] + } + + + {agent.label} + {agent.failureSummary ?? agent.statusLabel} + + + {agent.currentTask} + {agent.currentAction ? ( + {agent.currentAction} + ) : null} + {agent.waitingOn ? ( + {`等待:${agent.waitingOn}`} + ) : null} + {`任务完成 ${agent.completedCount}/${agent.totalCount}`} + + + ))} + + + ) : null} {resourceRecoveryPanelOpen ? (
) : null} - {approvalDialogOpen ? ( + {professionalDagVisible && approvalDialogOpen ? (
; + eagerPreviewLimit?: number; }) { const scopeKey = JSON.stringify([ input.projectPath, @@ -308,7 +318,10 @@ export function useProjectResourceCardPreviews(input: { job: PreviewJob, ): Promise => { const kind = projectResourceCardPreviewKind(job.resource); - if (kind === 'document' && job.resource.content !== undefined) { + if ( + (kind === 'document' || kind === 'code') && + job.resource.content !== undefined + ) { return { path: job.resource.path, mediaType: job.resource.mediaType, @@ -319,7 +332,7 @@ export function useProjectResourceCardPreviews(input: { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error( - kind === 'document' + kind === 'document' || kind === 'code' ? '文档预览需要在客户端内打开' : '媒体预览需要在客户端内打开', ); @@ -335,7 +348,7 @@ export function useProjectResourceCardPreviews(input: { }, ); } - if (kind === 'document') { + if (kind === 'document' || kind === 'code') { return invoke( 'read_local_project_text_preview', { @@ -595,6 +608,38 @@ export function useProjectResourceCardPreviews(input: { } }, [disposeCachedPreview, identityByResourceId]); + useEffect(() => { + const eagerPreviewLimit = Math.max( + 0, + Math.min( + PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, + Math.floor(input.eagerPreviewLimit ?? 0), + ), + ); + let requested = 0; + for (const resource of input.resources) { + if (requested >= eagerPreviewLimit) { + break; + } + const identity = identityByResourceId.get(resource.id); + const kind = projectResourceCardPreviewKind(resource); + if ( + identity && + kind !== 'version' && + kind !== 'placeholder' && + kind !== 'audio' + ) { + requestPreview(resource, identity, 'visible'); + requested += 1; + } + } + }, [ + identityByResourceId, + input.eagerPreviewLimit, + input.resources, + requestPreview, + ]); + useEffect( () => () => { cancelLocalProjectResourcePreviewScope(scopeIdRef.current); diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts index 43d94bec3..e54e5bb08 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts @@ -30,6 +30,7 @@ const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [ 'type', ]; const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ + 'code', 'document', 'version', 'art', diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index a671c4fa5..471d193c2 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -27,13 +27,16 @@ import { mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, + projectRuntimeStatusPresentation, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, projectSupervisorChatRuntimeStatus, projectSupervisorCollaboratingAgentRuntimes, projectSupervisorResponseStreamIdentity, + projectSupervisorRuntimeStatusLabel, projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, + resolveProjectSupervisorRuntimeSubmission, submitProjectSupervisorRuntimeTask, } from '../src/features/agent-runtime/model'; import { @@ -54,6 +57,76 @@ describe('普通用户工作区状态', () => { '正在读取项目', ); }); + + test.each([ + { + name: '普通 Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '游戏生成聊天', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: true, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '显式项目总控调试', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: true, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: 'Godot 项目调试', + input: { + workspaceProjectKind: 'godot' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: '显式专业 DAG Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', + }, + }, + ])('$name 使用稳定的 Runtime 提交路由', ({ input, expected }) => { + expect(resolveProjectSupervisorRuntimeSubmission(input)).toEqual(expected); + }); }); describe('Agent 最近任务失败摘要', () => { @@ -495,7 +568,7 @@ describe('Game Chat stream identity and source', () => { ).not.toBe(projectSupervisorResponseStreamIdentity(base)); }); - test('game-chat source is forwarded for start and steer without changing default source behavior', async () => { + test('Project Supervisor source is explicit for every start and steer', async () => { const runtimeResult = { state: { schemaVersion: 'game-creator-agent-runtime.v1', @@ -549,8 +622,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'chat', runtime: null, runProfile: 'standard', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); const steerRuntime = { @@ -583,13 +660,7 @@ describe('Game Chat stream identity and source', () => { ); invoke.mockClear(); - invoke.mockResolvedValueOnce({ - runtime: runtimeResult, - steerId: 'steer-2', - sequence: 2, - status: 'applied', - providerInterrupted: false, - }); + invoke.mockResolvedValueOnce(runtimeResult); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', @@ -597,8 +668,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'continue this run', runtime: steerRuntime, runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); await submitProjectSupervisorRuntimeTask({ @@ -752,6 +827,37 @@ function providerRetryRuntime(): AgentRuntimeState { } describe('Agent Runtime Provider 状态投影', () => { + test('单主管来源使用中性的游戏生成状态,GUI 调试来源保留专业任务状态', () => { + const gameChatRuntime = { + ...providerRetryRuntime(), + source: 'project-supervisor-game-chat', + phase: 'waiting-for-manifest-tasks', + currentAction: null, + waitingOn: null, + planSteps: [], + }; + const guiRuntime = { + ...gameChatRuntime, + source: 'project-supervisor-gui', + }; + + expect(projectSupervisorRuntimeStatusLabel(gameChatRuntime, '')).toBe( + '生成中', + ); + expect(projectRuntimeStatusPresentation(gameChatRuntime).label).toBe( + '生成中', + ); + expect(projectRuntimeVisibleCurrentWork(gameChatRuntime)).toBe( + '正在推进游戏生成', + ); + expect(projectSupervisorRuntimeStatusLabel(guiRuntime, '')).toBe( + '等待项目任务', + ); + expect(projectRuntimeVisibleCurrentWork(guiRuntime)).toBe( + '正在等待项目专业任务完成', + ); + }); + test('等待 503 重试时优先显示 Runtime 的真实动作和等待时间', () => { const runtime = providerRetryRuntime(); const expected = @@ -887,6 +993,122 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key'); }); + test('直连智能创作的错误码也展示可行动原因并识别终态未知', () => { + expect( + projectRuntimeVisibleError( + 'codex-app-server-error:usage-limit-exceeded', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试'); + expect( + projectRuntimeVisibleError( + 'codex-app-server-terminal-unknown: 等待 turn/completed 超时', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试', + ); + }); + + test('直连智能创作展示脱敏的阶段诊断与重试建议', () => { + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败:HTTP 503;建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:HTTP 503。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复;建议:历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复。历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据;建议:请检查当前 Windows 用户对本机私有凭据目录的权限后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:本机开发者凭据存储目录未安全初始化;未创建远端凭据。请检查当前 Windows 用户对本机私有凭据目录的权限后重试', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败: [redacted-secret];建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + }); + + test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => { + expect( + projectRuntimeVisibleError( + 'direct-codex-error:Codex app-server turn 失败:HTTP 400', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:Codex 执行失败:Codex app-server turn 失败:HTTP 400', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-error:请求失败 https://provider.example/private C:\\Users\\private\\project', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:Codex 执行失败:请求失败 [已隐藏链接] [已隐藏路径]', + ); + + expect( + projectRuntimeVisibleError( + 'Codex 已返回,但客户端登记生成产物失败:游戏代码已生成,但未在实际渲染中同时使用陶泥儿背景图 assets/direct-game-background.png 与四类核心切片;不会将项目标记为完成', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:Codex 已返回,但客户端登记生成产物失败:游戏代码已生成,但未在实际渲染中同时使用陶泥儿背景图 assets/direct-game-background.png 与四类核心切片;不会将项目标记为完成', + ); + + expect( + projectRuntimeVisibleError( + '陶泥儿美术包不完整,已终止代码生成', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作:陶泥儿美术包不完整,已终止代码生成'); + }); + + test('直连陶泥儿平台图集失败保留安全可操作原因并隐藏 operationId', () => { + const visible = projectRuntimeVisibleError( + '陶泥儿美术包生成失败(陶泥儿首版核心游戏美术图集),已在启动智能创作前终止:平台图片生成任务失败:game-chat 图集必须由 External Editor 以 grid-2x2 固定切片合同生成;operationId=private-operation-id', + '陶泥儿智能创作', + true, + ); + expect(visible).toBe( + '陶泥儿智能创作:陶泥儿美术包生成失败(陶泥儿首版核心游戏美术图集),已在启动智能创作前终止:平台图片生成任务失败:game-chat 图集必须由 External Editor 以 grid-2x2 固定切片合同生成', + ); + expect(visible).not.toContain('operationId'); + expect( + projectRuntimeVisibleError( + '陶泥儿美术包生成失败:https://provider.example/private?api_key=secret', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + }); + test('把常见 Runtime 失败映射为可行动原因', () => { expect( projectRuntimeVisibleError( diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 67f247cdd..64eb16017 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,3 +1,13 @@ +import { afterEach } from 'vitest'; + +import { resetClientServerSelectionForTests } from '../../src/services/clientHttp'; +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, + resetPlatformSessionStateForTests, +} from '../../src/services/platformSession'; import { AuthenticatedClient, expect, @@ -8,9 +18,183 @@ import { screen, testAuthUser, vi, + waitFor, } from './harness'; export function registerAuthTests() { + afterEach(() => { + resetPlatformSessionStateForTests(); + resetClientServerSelectionForTests(); + delete window.__TAURI__; + }); + + it('keeps the client at login when the native platform session cannot be installed', async () => { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string) => { + if (command === 'install_platform_account_session') { + throw new Error('runner unavailable'); + } + return null; + }), + }, + }; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + return new Response( + JSON.stringify({ + token: 'phone-token', + user: { ...testAuthUser, loginMethod: 'phone' }, + created: false, + referral: null, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.id), + ), + ); + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('runner unavailable')).not.toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('phone-token'); + }); + + it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const initialGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); + + let resolveRefresh: ((response: Response) => void) | null = null; + let refreshCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + refreshCalls += 1; + return await new Promise((resolve) => { + resolveRefresh = resolve; + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const first = requestPlatformSessionRefresh(testAuthUser.id); + const second = requestPlatformSessionRefresh(testAuthUser.id); + expect(first).toBe(second); + expect(refreshCalls).toBe(1); + + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + await commitAuthenticatedPlatformSession(accountB, accountBGeneration); + resolveRefresh?.( + new Response(JSON.stringify({ token: 'late-account-a-token' }), { + status: 200, + }), + ); + + await expect(first).resolves.toEqual({ status: 'stale' }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-b-token'); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: 'user-b', + accessToken: 'account-b-token', + generation: currentPlatformSessionGeneration(), + }), + ); + }); + + it('refreshes the current account once and synchronizes the replacement token', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'expired-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + let refreshCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + refreshCalls += 1; + return new Response(JSON.stringify({ token: 'replacement-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + expect(result).toEqual( + expect.objectContaining({ status: 'refreshed', user: testAuthUser }), + ); + expect(refreshCalls).toBe(1); + await waitFor(() => { + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'replacement-token', + }), + ); + }); + }); + it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -83,6 +267,39 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); + it('shows release, dev, and custom server choices on the login screen', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + if (String(input) === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + throw new Error(`unexpected fetch ${String(input)}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + const server = screen.getByRole('combobox', { name: '服务器' }); + expect(server).not.toBeNull(); + expect(screen.getByRole('option', { name: 'release' })).not.toBeNull(); + expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull(); + expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull(); + + fireEvent.change(server, { target: { value: 'custom' } }); + expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull(); + fireEvent.change(screen.getByLabelText('自定义服务器地址'), { + target: { value: 'https://staging.example.com' }, + }); + expect( + (screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value, + ).toBe('https://staging.example.com'); + }); + it('logs in with a phone code and stores the returned token', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -294,6 +511,44 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); + it('explains a refused login connection without exposing transport details', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + throw new TypeError('connect ECONNREFUSED 127.0.0.1:8082'); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect( + await screen.findByText( + '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口', + ), + ).not.toBeNull(); + expect(screen.queryByText(/ECONNREFUSED|127\.0\.0\.1:8082/u)).toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + }); + it('keeps the stored token when startup auth check cannot reach the service', async () => { window.localStorage.setItem( 'genarrative.auth.access-token.v1', diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 956b5e093..a3aaa697d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -538,6 +538,15 @@ function createProjectSupervisorRuntimeHarness({ }; }) => void) | null = null; + let progressHandler: + | ((event: { + payload: { + projectPath: string; + stage: string; + message: string; + }; + }) => void) + | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -837,6 +846,9 @@ function createProjectSupervisorRuntimeHarness({ manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler; } + if (eventName === 'game-creator-agent-progress') { + progressHandler = handler as unknown as typeof progressHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -844,6 +856,9 @@ function createProjectSupervisorRuntimeHarness({ if (manifestInvalidatedHandler === handler) { manifestInvalidatedHandler = null; } + if (progressHandler === handler) { + progressHandler = null; + } }; }, ); @@ -928,6 +943,15 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitProgress(stage: string, message: string) { + progressHandler?.({ + payload: { + projectPath, + stage, + message, + }, + }); + }, }; } 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 fc5dccdaa..71e652fcf 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,4 +1,3 @@ -import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants'; import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import { @@ -1272,14 +1271,14 @@ export function registerHomeProjectCreationTests() { expect(await screen.findByText('已取消')).not.toBeNull(); }); - it('keeps the home draft responsive while the native directory picker is open', async () => { - let finishDirectoryPick: ((path: string | null) => void) | null = null; - const directoryPick = new Promise((resolve) => { - finishDirectoryPick = resolve; + it('keeps the home draft responsive while automatic workspace creation is in progress', async () => { + let failAutomaticProject: ((error: Error) => void) | null = null; + const automaticProject = new Promise((_resolve, reject) => { + failAutomaticProject = reject; }); const invoke = vi.fn(async (command: string) => { - if (command === 'pick_local_project_directory') { - return directoryPick; + if (command === 'create_automatic_local_game_project') { + return automaticProject; } throw new Error(`unexpected invoke ${command}`); }); @@ -1297,10 +1296,13 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'pick_local_project_directory', - undefined, + 'create_automatic_local_game_project', ); }); + expect(invoke).not.toHaveBeenCalledWith( + 'pick_local_project_directory', + expect.anything(), + ); expect((createButton as HTMLButtonElement).disabled).toBe(true); nativeClipboardMock.text = ',加入动作战斗'; @@ -1310,16 +1312,16 @@ export function registerHomeProjectCreationTests() { }); await act(async () => { - finishDirectoryPick?.(null); - await directoryPick; + failAutomaticProject?.(new Error('自动创建测试结束')); + await automaticProject.catch(() => undefined); }); await waitFor(() => { expect((createButton as HTMLButtonElement).disabled).toBe(false); }); - expect(screen.getAllByText('已取消')).not.toHaveLength(0); + expect(screen.getAllByText('自动创建测试结束')).not.toHaveLength(0); }); - it('creates an automatic workspace on Enter and sends the first requirement once to the active Project Supervisor Session', async () => { + it('creates an automatic workspace on Enter and sends the first requirement once to direct Codex', async () => { const automaticProjectPath = 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-a1b2c3d4'; const manifest = createGameCreationAppManifest( @@ -1347,6 +1349,9 @@ export function registerHomeProjectCreationTests() { manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, }; } + if (command === 'chat_with_game_creator_direct_codex') { + return 'DIRECT_CODEX_TEST_OK'; + } return supervisorHarness.invoke(command, args); }, ); @@ -1412,6 +1417,11 @@ export function registerHomeProjectCreationTests() { expect(screen.getByLabelText('项目总控消息').textContent).toContain( '第一行\n第二行\n第三行', ); + await waitFor(() => { + expect(screen.getByLabelText('项目总控消息').textContent).toContain( + 'DIRECT_CODEX_TEST_OK', + ); + }); expect( await screen.findByRole('button', { name: '打开资源详情:美术资源 reference.png', @@ -1436,49 +1446,60 @@ export function registerHomeProjectCreationTests() { mediaType: 'image/png', bytes: fileBytes, }); - expect(invoke).toHaveBeenCalledWith('list_game_creator_agent_sessions', { + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, - agentId: 'project-supervisor', + prompt: expect.stringContaining('初始意图:art / 做素材'), }); - expect(invoke).toHaveBeenCalledWith('create_game_creator_agent_session', { + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, - agentId: 'project-supervisor', - title: '项目总控', + prompt: expect.stringContaining( + '第一行\n第二行\n第三行{1st attachment: reference.png}', + ), }); const startCalls = invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', ); - expect(startCalls).toHaveLength(1); - expect(startCalls[0]?.[1]).toMatchObject({ - projectPath: automaticProjectPath, - sessionId: supervisorHarness.sessionId, - task: expect.stringContaining('初始意图:art / 做素材'), - runId: expect.stringMatching(/^project-supervisor-task-/), - runProfile: 'autonomous-game-build', - }); - expect(startCalls[0]?.[1]).not.toHaveProperty('source'); - expect(startCalls[0]?.[1]).toMatchObject({ - task: expect.stringContaining( - '第一行\n第二行\n第三行{1st attachment: reference.png}', - ), - }); - expect(startCalls[0]?.[1]).toMatchObject({ - task: expect.stringContaining('附件:reference.png'), - }); - await waitFor(() => { - expect( - invoke.mock.calls.filter( - ([command]) => command === 'resume_game_creator_agent_runtime_tasks', - ), - ).toHaveLength(1); - }); + expect(startCalls).toHaveLength(0); expect( invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record)?.agentId === null, + ([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( + directConversationMessageIds[0].replace(/:(?:user|assistant)$/u, ''), + ).toBe( + directConversationMessageIds[1].replace(/:(?:user|assistant)$/u, ''), + ); expect(invoke).not.toHaveBeenCalledWith( 'generate_local_game_draft', expect.anything(), @@ -1498,11 +1519,10 @@ export function registerHomeProjectCreationTests() { }); it.each([ - ['做游戏', false, 'autonomous-game-build', undefined], - ['做方案', false, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], - ['做方案', true, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], + ['做方案', false, 'standard', 'project-supervisor-plan'], + ['做方案', true, 'standard', 'project-supervisor-plan'], ] as const)( - 'routes %s %s creation to the expected root run', + 'routes %s %s creation to the expected Runtime root', async (modeLabel, automatic, runProfile, source) => { const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`; const manifest = createGameCreationAppManifest( @@ -1578,57 +1598,166 @@ export function registerHomeProjectCreationTests() { invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', ), - ).toHaveLength(automatic ? 1 : 0); + ).toHaveLength(1); }, ); - it('keeps the planning entry after confirming a non-empty folder from 做方案', async () => { - const projectPath = '/tmp/home-doc-non-empty'; - const supervisorHarness = createProjectSupervisorRuntimeHarness({ - projectPath, - expectedRunProfile: 'standard', - }); + it('hydrates an existing project before sending a direct Codex turn', async () => { + const projectPath = + 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\existing-project'; + const manifest = createGameCreationAppManifest( + 'existing-project', + '已有项目', + ); const invoke = vi.fn( async (command: string, args?: Record) => { - if (command === 'pick_local_project_directory') { - return projectPath; + if (command === 'get_local_game_manifest') { + expect(args).toEqual({ projectPath }); + return manifest; } - if (command === 'is_local_project_directory_non_empty') { - return true; + if (command === 'append_local_permission_log') { + return {}; } - return supervisorHarness.invoke(command, args); + if (command === 'chat_with_game_creator_direct_codex') { + return 'DIRECT_EXISTING_PROJECT_OK'; + } + throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { - core: { invoke }, - event: { listen: supervisorHarness.listen }, - }; - renderLauncherAt('/?launcher', 'home', true); + window.__TAURI__ = { core: { invoke } }; - fireEvent.click(screen.getByRole('button', { name: '做方案' })); - const promptInput = screen.getByLabelText('创作想法'); - nativeClipboardMock.text = '整理可执行的玩法立项方案'; - fireEvent.paste(promptInput); - await waitFor(() => { - expect(promptInput.textContent).toContain('整理可执行的玩法立项方案'); - }); - fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); - - expect( - await screen.findByRole('dialog', { name: '文件夹不是空的' }), - ).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '继续新建' })); + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + initialSupervisorMessage: '继续修改已有项目', + }), + ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - expect.objectContaining({ + 'chat_with_game_creator_direct_codex', + { projectPath, - runProfile: 'standard', - source: PROJECT_SUPERVISOR_PLAN_SOURCE, - }), + prompt: '继续修改已有项目', + }, ); }); + expect(invoke).not.toHaveBeenCalledWith( + 'create_automatic_local_game_project', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.anything(), + ); + }); + + it('shows a readable reason when the direct Codex turn is rejected', async () => { + const projectPath = + 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project'; + const manifest = createGameCreationAppManifest( + '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'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + initialSupervisorMessage: '生成一个游戏', + }), + ); + + 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); }); } @@ -2246,7 +2375,7 @@ export function registerRecentProjectsTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/non-empty-game', - projectId: expect.stringMatching(/^local-project-/), + projectId: 'local-project-draft', name: 'non-empty-game', }); expect(screen.getByLabelText('项目总控对话')).not.toBeNull(); @@ -2289,7 +2418,7 @@ export function registerRecentProjectsTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/folder-named-game/', - projectId: expect.stringMatching(/^local-project-/), + projectId: 'local-project-draft', name: 'folder-named-game', }); }); 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 8f68d87ff..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 @@ -2,18 +2,15 @@ import type { ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { consumeInitialGameChatMessage } from '../../src/App'; +import { + consumeInitialGameChatMessage, + latestGameChatPlayableRevision, +} from '../../src/App'; import type { AgentRuntimeEventRecord, AgentRuntimeState, } from '../../src/app/types'; -import { - AgentRuntimeStatusPanel, - latestGameChatPlayableRevision as projectLatestGameChatPlayableRevision, - MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, - projectCurrentGameChatRuntimeLineage, - ProjectSupervisorRuntimePanel, -} from '../../src/features/agent-runtime'; +import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE } from '../../src/features/agent-runtime/model'; import { buildGameChatProgressEvidence, collectGameChatResultImages, @@ -188,7 +185,6 @@ function gameChatRuntimeEvent({ updatedAt, eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`, publicText = summary, - source, }: { agentId?: string; taskId?: string; @@ -201,7 +197,6 @@ function gameChatRuntimeEvent({ detail?: string | null; eventId?: string; publicText?: string | null; - source?: string; updatedAt: number; }): AgentRuntimeEventRecord { return { @@ -211,10 +206,9 @@ function gameChatRuntimeEvent({ sessionId, runId, source: - source ?? - (agentId === 'project-supervisor' + agentId === 'project-supervisor' ? 'project-supervisor-game-chat' - : 'agent-delegate'), + : 'agent-delegate', eventId, eventType, status, @@ -254,43 +248,21 @@ function gameChatRuntimeState( }; } -function latestGameChatPlayableRevision( - root: AgentRuntimeState, - runtimeByAgentId: Record, -) { - return projectLatestGameChatPlayableRevision( - projectCurrentGameChatRuntimeLineage(root, runtimeByAgentId), - ); +const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', + 'art-director', + 'art-asset-plan', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function isGameChatStageTask(taskId: string) { + return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId); } -function gameChatMainRuntimeWithEvents( - root: AgentRuntimeState, - events: AgentRuntimeEventRecord[], -) { - const main = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: `${root.runId}-main-session`, - runId: `${root.runId}-main-run`, - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: root.runId, - status: 'running', - phase: 'execution', - updatedAt: root.updatedAt, - }); - main.recentEvents = events.map((event) => ({ - ...event, - agentId: main.agentId, - taskId: main.taskId, - sessionId: main.sessionId, - runId: main.runId, - source: main.source, - })); - return main; -} - -function gameChatPlayableMainRuntime({ +function gameChatPreviewPlaytestRuntime({ parentRunId, revision, updatedAt, @@ -299,11 +271,11 @@ function gameChatPlayableMainRuntime({ revision: number; updatedAt: number; }): AgentRuntimeState { - const runId = `code-prototype-${parentRunId}-${revision}`; - const sessionId = `code-prototype-session-${revision}`; + const runId = `preview-playtest-${parentRunId}-${revision}`; + const sessionId = `preview-playtest-session-${revision}`; return gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId, runId, source: 'agent-ready-task-scheduler', @@ -311,28 +283,15 @@ function gameChatPlayableMainRuntime({ parentRunId, status: 'completed', phase: 'completed', - currentTask: '完成单主静态自检与浏览器试玩验证', + currentTask: '执行浏览器试玩验证', currentGoal: '确认当前 revision 可以试玩', - currentAction: '静态自检与浏览器试玩验证已通过', + currentAction: '浏览器试玩验证已通过', recentEvents: [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId, runId, - source: 'agent-ready-task-scheduler', - eventType: 'observation', - status: 'completed', - phase: 'tool-observation', - summary: 'command.run_limited:ok · game.static_smoke 已完成', - updatedAt: Math.max(0, updatedAt - 1), - }), - gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId, - runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -547,7 +506,7 @@ async function renderGameChatAutoPreviewDriver({ }; const emitValidation = async (revision: number, validatedAt: number) => { harness.setProjectRevision(revision); - const runtime = gameChatPlayableMainRuntime({ + const runtime = gameChatPreviewPlaytestRuntime({ parentRunId, revision, updatedAt: validatedAt, @@ -766,9 +725,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull(); expect(screen.getByLabelText('测试项目总控')).not.toBeNull(); expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); - expect( - screen.getByRole('article', { name: /设计实现 Agent/ }), - ).not.toBeNull(); + expect(screen.getByRole('article', { name: /策划 Agent/ })).not.toBeNull(); expect(screen.getByRole('article', { name: /美术 Agent/ })).not.toBeNull(); expect(screen.getByRole('article', { name: /程序 Agent/ })).not.toBeNull(); @@ -777,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( @@ -3468,7 +3429,7 @@ export function registerProjectWorkbenchFoundationTests() { ); expect(styles).toMatch( - /\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - 72px\)[^}]*padding:\s*40px 8px 8px[^}]*overflow:\s*hidden/s, + /\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - 72px\)[^}]*padding:\s*72px 8px 8px[^}]*overflow:\s*hidden/s, ); expect(styles).toMatch( /@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/, @@ -3488,6 +3449,13 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-composer button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s, ); + expect(styles).toMatch( + /\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s, + ); + expect(styles).toMatch( + /\.game-run-preview iframe\s*\{[^}]*position:\s*absolute[^}]*inset:\s*0[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-height:\s*0/s, + ); + expect(styles).not.toMatch(/\.game-run-slice-controls/); const agentDockRule = styles.match(/\.game-agent-dock\s*\{([^}]*)\}/s); expect(agentDockRule?.[1]).toContain('overflow: visible;'); expect(agentDockRule?.[1]).not.toContain('overflow-x:'); @@ -3560,6 +3528,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ); window.__TAURI__ = { core: { invoke } }; + const onPlay = vi.fn(); render( React.createElement(ProjectDevelopmentView, { @@ -3572,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; @@ -3588,7 +3562,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(previewFrame.getAttribute('sandbox')).toBe( 'allow-scripts allow-same-origin allow-forms allow-pointer-lock', ); - expect(screen.getByLabelText('测试切片控件')).not.toBeNull(); + expect(screen.queryByLabelText('测试切片控件')).toBeNull(); expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); expect(screen.getByLabelText('数值微调面板')).not.toBeNull(); @@ -3697,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(); }); } @@ -3949,7 +3927,56 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { - it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => { + 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( 'local-project-draft', @@ -3993,13 +4020,12 @@ export function registerProjectSupervisorSurfaceTests() { await within(messageList).findByText('想做什么游戏?'), ).not.toBeNull(); expect( - within(supervisorSurface).getByLabelText('项目总控 Agent 状态'), - ).not.toBeNull(); + within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'), + ).toBeNull(); expect( - within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'), - ).not.toBeNull(); - expect( - within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'), + within(supervisorSurface).getByPlaceholderText( + '告诉陶泥儿接下来要做什么', + ), ).not.toBeNull(); expect( within(supervisorSurface).getByRole('button', { name: '发送' }), @@ -4064,7 +4090,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(messageList.textContent).not.toContain('chars='); }); - it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => { + it('hydrates a persisted needs-reconciliation Supervisor runtime read-only without an active Session index', async () => { const projectPath = '/tmp/launcher-reconciliation-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -4092,18 +4118,6 @@ export function registerProjectSupervisorSurfaceTests() { error: 'tool-plan-unknown', updatedAt: 7000, }; - const cancelledQueue = { - total: 2, - pending: 1, - running: 0, - waitingForConfirmation: 0, - waitingForUserInput: 0, - cancelled: 1, - completed: 0, - failed: 0, - latestRunId: 'persisted-reconciliation-run', - updatedAt: 8000, - }; const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, initialSessionExists: false, @@ -4126,22 +4140,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'cancel_game_creator_agent_runtime_task') { - const state = supervisorHarness.runtimeState({ - ...reconciliationRuntime, - status: 'cancelled', - phase: 'cancelled', - currentAction: '待核对的旧任务已结束', - waitingOn: '队列中的下一个任务', - error: null, - taskQueue: cancelledQueue, - updatedAt: 8000, - }); - return { - ...supervisorHarness.runtimeResult(state), - taskQueue: cancelledQueue, - }; - } return supervisorHarness.invoke(command, args); }, ); @@ -4155,37 +4153,34 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorSurface = await screen.findByLabelText('项目总控对话'); expect( - await within(supervisorSurface).findByText('项目总控 Agent · 待核对'), - ).not.toBeNull(); + within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'), + ).toBeNull(); expect( - within(supervisorSurface).getByText('当前阶段:待核对'), - ).not.toBeNull(); + within(supervisorSurface).queryByText('项目总控 Agent · 待核对'), + ).toBeNull(); + expect( + within(supervisorSurface).queryByText('tool-plan-unknown'), + ).toBeNull(); expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', { projectPath, }); - const reconcileButton = within(supervisorSurface).getByRole('button', { - name: '已核对,结束旧任务', - }); - fireEvent.click(reconcileButton); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'cancel_game_creator_agent_runtime_task', - { - projectPath, - agentId: 'project-supervisor', - runId: 'persisted-reconciliation-run', - }, - ); - }); + expect( + within(supervisorSurface).queryByRole('button', { + name: '已核对,结束旧任务', + }), + ).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'resume_game_creator_agent_runtime_tasks', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'cancel_game_creator_agent_runtime_task', + expect.anything(), + ); expect(invoke).not.toHaveBeenCalledWith( 'confirm_retry_game_creator_agent_runtime_task', expect.anything(), ); - expect( - await within(supervisorSurface).findByText( - '旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理', - ), - ).not.toBeNull(); expect( within(supervisorSurface).queryByRole('button', { name: '在当前项目重试总控', @@ -4193,7 +4188,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toBeNull(); }); - it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => { + it('keeps a reconciled Supervisor read-only while allowing a new direct Codex turn', async () => { const projectPath = '/tmp/launcher-reconciliation-empty-queue'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -4222,18 +4217,6 @@ export function registerProjectSupervisorSurfaceTests() { error: 'tool-plan-unknown', updatedAt: 7000, }; - const emptyQueue = { - total: 1, - pending: 0, - running: 0, - waitingForConfirmation: 0, - waitingForUserInput: 0, - cancelled: 1, - completed: 0, - failed: 0, - latestRunId: runId, - updatedAt: 8000, - }; const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, sessionId: 'reconciliation-empty-queue-session', @@ -4257,37 +4240,17 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'cancel_game_creator_agent_runtime_task') { - const state = supervisorHarness.runtimeState({ - ...reconciliationRuntime, - status: 'cancelled', - phase: 'cancelled', - currentAction: '待核对的旧任务已结束', - waitingOn: '', - error: null, - taskQueue: emptyQueue, - updatedAt: 8000, - }); - return { - ...supervisorHarness.runtimeResult(state), - taskQueue: emptyQueue, - }; + if (command === 'chat_with_game_creator_direct_codex') { + return 'DIRECT_AFTER_RECONCILIATION_OK'; } - if (command === 'confirm_retry_game_creator_agent_runtime_task') { - const nextRunId = String(args?.nextRunId ?? ''); - const state = supervisorHarness.runtimeState({ - ...reconciliationRuntime, - runId: nextRunId, - status: 'running', - phase: 'planning', - currentAction: '重新生成项目总控计划', - waitingOn: 'Agent 输出计划或回复', - error: null, - updatedAt: 9000, - }); + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { return { - ...supervisorHarness.runtimeResult(state), - acceptedRunId: nextRunId, + url: 'http://127.0.0.1:43123/game/index.html', + port: 43123, + root: projectPath, }; } return supervisorHarness.invoke(command, args); @@ -4302,41 +4265,35 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('项目总控对话'); - fireEvent.click( - await within(supervisorSurface).findByRole('button', { - name: '已核对,结束旧任务', - }), - ); + expect( + within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'), + ).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', { + projectPath, + }); + const input = within(supervisorSurface).getByLabelText('项目需求'); + fireEvent.change(input, { + target: { value: '从旧任务状态继续生成,但使用 direct Codex' }, + }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + expect( await within(supervisorSurface).findByText( - '旧任务已结束,当前队列为空,可重新启动项目总控', + 'DIRECT_AFTER_RECONCILIATION_OK', ), ).not.toBeNull(); - const retryButton = await within(supervisorSurface).findByRole('button', { - name: '在当前项目重试总控', + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + projectPath, + prompt: '从旧任务状态继续生成,但使用 direct Codex', }); - fireEvent.click(retryButton); - - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'confirm_retry_game_creator_agent_runtime_task', - { - projectPath, - agentId: 'project-supervisor', - runId, - nextRunId: expect.stringMatching(/^project-supervisor-retry-/), - }, - ); - }); - const cancelCallIndex = invoke.mock.calls.findIndex( - ([command]) => command === 'cancel_game_creator_agent_runtime_task', + expect(invoke).not.toHaveBeenCalledWith( + 'cancel_game_creator_agent_runtime_task', + expect.anything(), ); - const retryCallIndex = invoke.mock.calls.findIndex( - ([command]) => - command === 'confirm_retry_game_creator_agent_runtime_task', + expect(invoke).not.toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + expect.anything(), ); - expect(cancelCallIndex).toBeGreaterThanOrEqual(0); - expect(retryCallIndex).toBeGreaterThan(cancelCallIndex); }); it('starts the game-chat surface as project-free chat with project and settings controls', () => { @@ -4371,47 +4328,111 @@ export function registerProjectSupervisorSurfaceTests() { ); }); - it('exposes External Editor credentials from the game-chat settings', async () => { + it('keeps External Editor credentials hidden from ordinary game-chat settings', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_game_creator_app_config') { return { path: '/home/test/AppData/game-creator.config.json', config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: false, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: 'game-chat-editor-secret', }, + mcpServers: {}, }, }; } throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderGameChatStatus({ - runtime: gameChatRuntimeState(), - viewOverrides: { - onOpenRuntimeConfig: vi.fn(), - runtimeConfigOpen: true, - }, - }); + render( + React.createElement(App, { + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + fireEvent.click(screen.getByRole('button', { name: '设置' })); + + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); + expect(screen.queryByDisplayValue('game-chat-editor-secret')).toBeNull(); + }); + + it('shows External Editor credentials only in the standalone game-chat release', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-chat/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: false, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { + baseUrl: 'https://editor.example.test', + apiKey: 'game-chat-editor-secret', + }, + mcpServers: {}, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(GameChatReleaseApp, { + allowAdvancedExternalEditorConfig: true, + }), + ); + + fireEvent.click(screen.getByRole('button', { name: '设置' })); expect( await screen.findByRole('dialog', { name: '运行时配置' }), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( 'value', - 'http://127.0.0.1:8082', + 'https://editor.example.test', ); expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( - 'type', - 'password', + 'value', + 'game-chat-editor-secret', ); - expect( - screen - .getByLabelText('External Editor API Key') - .getAttribute('autocomplete'), - ).toBe('off'); }); it('opens the dedicated game-chat release without platform authentication', async () => { @@ -4709,7 +4730,7 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath, - projectId: expect.stringMatching(/^local-project-/), + projectId: 'local-project-draft', name: 'game-chat-non-empty', }); }); @@ -5012,54 +5033,30 @@ export function registerProjectSupervisorSurfaceTests() { duplicate, ], }); - const currentMain = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'code-session', - runId: 'code-run', + const currentChild = gameChatRuntimeState({ + agentId: 'design-foundation', + taskId: 'design-foundation', + sessionId: 'design-session', + runId: 'design-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: 'active-parent-run', updatedAt: 30, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - sessionId: 'code-session', - runId: 'code-run', - source: 'agent-ready-task-scheduler', - summary: '当前主 Agent 事件', + agentId: 'design-foundation', + runId: 'design-run', + summary: '当前子 Agent 事件', updatedAt: 30, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - sessionId: 'code-session', - runId: 'previous-code-run', - source: 'agent-ready-task-scheduler', - summary: '主 Agent 旧 run 事件', + agentId: 'design-foundation', + runId: 'previous-design-run', + summary: '子 Agent 旧 run 事件', updatedAt: 60, }), ], }); - const currentArtChild = gameChatRuntimeState({ - agentId: 'art-asset-plan', - taskId: 'art-asset-plan', - sessionId: 'current-art-session', - runId: 'current-art-run', - source: 'agent-delegate', - parentAgentId: 'code-prototype', - parentRunId: 'code-run', - updatedAt: 25, - recentEvents: [ - gameChatRuntimeEvent({ - agentId: 'art-asset-plan', - sessionId: 'current-art-session', - runId: 'current-art-run', - source: 'agent-delegate', - summary: '当前美术 child 事件', - updatedAt: 25, - }), - ], - }); const staleChild = gameChatRuntimeState({ agentId: 'art-asset-plan', taskId: 'art-asset-plan', @@ -5087,20 +5084,17 @@ export function registerProjectSupervisorSurfaceTests() { ]); const events = collectGameChatRuntimeEvents(supervisor, { - 'code-prototype': currentMain, - 'current-art-asset-plan': currentArtChild, - 'stale-art-asset-plan': staleChild, + 'design-foundation': currentChild, + 'art-asset-plan': staleChild, }); expect(events.map((item) => item.event.summary)).toEqual([ - '当前主 Agent 事件', - '当前美术 child 事件', + '当前子 Agent 事件', '总控事件 2', '总控事件 1', ]); expect(events.map((item) => item.agentLabel)).toEqual([ - '程序原型 Agent', - '美术资源计划 Agent', + '玩法策划 Agent', '项目总控 Agent', '项目总控 Agent', ]); @@ -5173,22 +5167,20 @@ export function registerProjectSupervisorSurfaceTests() { }), ], }); - const mainRuntime = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'code-prototype-session', - runId: 'code-prototype-main-run', - source: 'agent-ready-task-scheduler', + const designRuntime = gameChatRuntimeState({ + agentId: 'design-director', + taskId: 'design-director', + sessionId: 'design-director-session', + runId: 'design-director-child-run', + source: 'agent-delegate', parentAgentId: 'project-supervisor', parentRunId: runId, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - sessionId: 'code-prototype-session', - runId: 'code-prototype-main-run', - source: 'agent-ready-task-scheduler', + agentId: 'design-director', + runId: 'design-director-child-run', eventType: 'turn.started', - summary: 'Code prototype Agent started and must stay visible', + summary: 'Design Agent started and must stay visible', updatedAt: 32, }), ], @@ -5196,16 +5188,14 @@ export function registerProjectSupervisorSurfaceTests() { }); const messages = gameChatRuntimeEventMessages(runtime, { - 'code-prototype': mainRuntime, + 'design-director': designRuntime, }); expect(messages.map((message) => message.updatedAt)).toEqual([ 32, 40, 60, 80, ]); expect(messages.map((message) => message.text)).toEqual([ - expect.stringContaining( - 'Code prototype Agent started and must stay visible', - ), + expect.stringContaining('Design Agent started and must stay visible'), expect.stringContaining('Generated prototype progress'), expect.stringContaining('code agent repair collision'), expect.stringContaining('preview.validate:ok'), @@ -5256,7 +5246,14 @@ export function registerProjectSupervisorSurfaceTests() { gameChatRuntimeEventMessages(supervisorContinuation, {}).map( (message) => message.text, ), - ).toEqual([]); + ).toEqual([ + expect.stringContaining( + `Supervisor ${continuation.kind} continuation started`, + ), + expect.stringContaining( + `Supervisor ${continuation.kind} continuation failed`, + ), + ]); } }); @@ -5296,13 +5293,17 @@ export function registerProjectSupervisorSurfaceTests() { requestKind: 'tool-plan', }, ]); - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(7); expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('玩法方向已完成'), expect.stringContaining('视觉方向已完成'), expect.stringContaining('平台美术图集已生成并登记'), + expect.stringContaining('程序方案已完成'), expect.stringContaining('代码原型已完成'), + expect.stringContaining('预览就绪检查已完成'), + expect.stringContaining('试玩验证已完成'), ]); - expect(messages[0]?.messageId).toContain('art-director'); + expect(messages[0]?.messageId).toContain('design-director'); expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); expect(messages.every((message) => message.agentId)).toBe(true); const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( @@ -5316,145 +5317,6 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(1); }); - it('hides generic retry for exact game-chat dynamic art children and shows cross-round recovery guidance', () => { - const root = gameChatRuntimeState({ - runId: 'game-chat-dynamic-art-retry-root', - status: 'running', - phase: 'waiting-for-delegate-receipts', - }); - const main = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'game-chat-dynamic-art-main-session', - runId: 'game-chat-dynamic-art-main-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: root.runId, - status: 'running', - phase: 'waiting-for-delegate-receipts', - }); - const artDirector = gameChatRuntimeState({ - agentId: 'art-director', - taskId: 'art-director', - sessionId: 'game-chat-dynamic-art-director-session', - runId: 'game-chat-dynamic-art-director-run', - source: 'agent-delegate', - parentAgentId: 'code-prototype', - parentRunId: main.runId, - status: 'failed', - phase: 'failed', - currentAction: '动态美术委派失败', - }); - const legacyRetry = gameChatRuntimeState({ - agentId: 'art-asset-plan', - taskId: 'art-asset-plan', - sessionId: 'game-chat-dynamic-art-retry-session', - runId: 'game-chat-dynamic-art-retry-run', - source: 'agent-delegate-retry', - parentAgentId: 'code-prototype', - parentRunId: main.runId, - status: 'failed', - phase: 'failed', - currentAction: '遗留 retry 只作诊断展示', - }); - const wrongParentArt = gameChatRuntimeState({ - agentId: 'art-director', - taskId: 'art-director', - sessionId: 'game-chat-wrong-parent-art-session', - runId: 'game-chat-wrong-parent-art-run', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: root.runId, - status: 'failed', - phase: 'failed', - currentAction: '旧 root 直属美术不得显示', - }); - const callbacks = { - onProfessionalRetry: vi.fn(async () => 'unexpected retry'), - onProfessionalToolAction: vi.fn(async () => {}), - onSupervisorRetry: vi.fn(async () => 'unexpected supervisor retry'), - onToolAction: vi.fn(), - onUserInput: vi.fn(), - }; - const renderPanel = (runtime: AgentRuntimeState) => - React.createElement(ProjectSupervisorRuntimePanel, { - runtime, - error: '', - runtimeByAgentId: { - 'code-prototype': main, - 'art-director': artDirector, - 'art-asset-plan': legacyRetry, - 'wrong-parent-art': wrongParentArt, - }, - controlBusy: false, - professionalResultsByAgentId: {}, - ...callbacks, - }); - const rendered = render(renderPanel(root)); - const list = screen.getByLabelText('专业 Agent 实时状态'); - - expect( - within(list).queryByRole('button', { name: '在当前项目重试' }), - ).toBeNull(); - expect(list.textContent).not.toContain('旧 root 直属美术不得显示'); - expect(callbacks.onProfessionalRetry).not.toHaveBeenCalled(); - - rendered.rerender( - renderPanel({ ...root, status: 'completed', phase: 'completed' }), - ); - expect( - within(screen.getByLabelText('专业 Agent 实时状态')).getAllByText( - '请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派', - ), - ).toHaveLength(2); - }); - - it('disables generic retry when a selected runtime claims nested game-chat art lineage', () => { - const onRetryRuntimeTask = vi.fn(); - const nestedArt = gameChatRuntimeState({ - agentId: 'art-director', - taskId: 'art-director', - sessionId: 'selected-game-chat-art-session', - runId: 'selected-game-chat-art-run', - source: 'agent-delegate', - parentAgentId: 'code-prototype', - parentRunId: 'selected-game-chat-main-run', - status: 'failed', - phase: 'failed', - }); - const rendered = render( - React.createElement(AgentRuntimeStatusPanel, { - runtime: nestedArt, - onRetryRuntimeTask, - }), - ); - const retryButton = within( - screen.getByLabelText('Agent Runtime 操作'), - ).getByRole('button', { name: '重试' }) as HTMLButtonElement; - expect(retryButton.disabled).toBe(true); - fireEvent.click(retryButton); - expect(onRetryRuntimeTask).not.toHaveBeenCalled(); - - rendered.rerender( - React.createElement(AgentRuntimeStatusPanel, { - runtime: { - ...nestedArt, - parentAgentId: 'project-supervisor', - parentRunId: 'full-dag-root-run', - }, - onRetryRuntimeTask, - }), - ); - expect( - ( - within(screen.getByLabelText('Agent Runtime 操作')).getByRole( - 'button', - { name: '重试' }, - ) as HTMLButtonElement - ).disabled, - ).toBe(false); - }); - it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => { const projectPath = '/tmp/game-chat-final-reply-hydration'; const runId = 'game-chat-final-reply-hydration-run'; @@ -5465,31 +5327,20 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'execution', updatedAt: 100, }); - const mainRunId = `code-prototype-${runId}`; const makeRuntimeResult = ( agentId: string, status: 'ready' | 'committed', text: string, updatedAt: number, ) => { - const currentMain = agentId === 'code-prototype'; - const currentDynamicArt = ['art-director', 'art-asset-plan'].includes( - agentId, - ); const state = gameChatRuntimeState({ agentId, taskId: agentId, - sessionId: `${agentId}-session-active`, - runId: currentMain ? mainRunId : `${agentId}-${runId}`, - source: currentMain - ? 'agent-ready-task-scheduler' - : currentDynamicArt - ? 'agent-delegate' - : 'agent-ready-task-scheduler', - parentAgentId: currentDynamicArt - ? 'code-prototype' - : 'project-supervisor', - parentRunId: currentDynamicArt ? mainRunId : runId, + sessionId: 'supervisor-session-active', + runId: `${agentId}-${runId}`, + source: 'agent-delegate', + parentAgentId: 'project-supervisor', + parentRunId: runId, status: 'completed', phase: 'completed', updatedAt, @@ -5502,7 +5353,7 @@ export function registerProjectSupervisorSurfaceTests() { schemaVersion: 'game-creator-runtime-response-stream.v1', agentId, taskId: agentId, - sessionId: state.sessionId, + sessionId: 'supervisor-session-active', runId: state.runId, requestKind: 'final-reply', requestSlot: 'final-reply-loop-1-revision-1', @@ -5576,8 +5427,8 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getByText(/视觉方向已完成/)).not.toBeNull(); expect(screen.getByText(/平台美术图集已生成并登记/)).not.toBeNull(); expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); - expect(screen.queryByText(/预览就绪已完成/)).toBeNull(); - expect(screen.queryByText(/试玩验证已完成/)).toBeNull(); + expect(screen.getByText(/预览就绪已完成/)).not.toBeNull(); + expect(screen.getByText(/试玩验证已完成/)).not.toBeNull(); }); const finalReplyAppends = () => invoke.mock.calls.filter( @@ -5586,14 +5437,14 @@ export function registerProjectSupervisorSurfaceTests() { String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), ); await waitFor(() => { - expect(finalReplyAppends()).toHaveLength(3); + expect(finalReplyAppends()).toHaveLength(7); }); rendered.unmount(); rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); }); - expect(finalReplyAppends()).toHaveLength(3); + expect(finalReplyAppends()).toHaveLength(7); rendered.unmount(); }); @@ -5625,7 +5476,6 @@ export function registerProjectSupervisorSurfaceTests() { initialRuntime: { sessionId: 'supervisor-session-active', runId, - source: 'project-supervisor-game-chat', status: 'running', phase: 'execution', recentEvents: events, @@ -5743,16 +5593,7 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence( - runtime, - { - 'code-prototype': gameChatMainRuntimeWithEvents( - runtime, - runtime.recentEvents ?? [], - ), - }, - null, - ); + const progress = buildGameChatProgressEvidence(runtime, {}, null); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -5779,16 +5620,7 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence( - runtime, - { - 'code-prototype': gameChatMainRuntimeWithEvents( - runtime, - runtime.recentEvents ?? [], - ), - }, - null, - ); + const progress = buildGameChatProgressEvidence(runtime, {}, null); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -5820,16 +5652,7 @@ export function registerProjectSupervisorSurfaceTests() { }); expect( - buildGameChatProgressEvidence( - runtime, - { - 'code-prototype': gameChatMainRuntimeWithEvents( - runtime, - runtime.recentEvents ?? [], - ), - }, - null, - )?.evidence, + buildGameChatProgressEvidence(runtime, {}, null)?.evidence, ).toEqual([expect.objectContaining({ label, tone })]); } }); @@ -5856,16 +5679,7 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence( - runtime, - { - 'code-prototype': gameChatMainRuntimeWithEvents( - runtime, - runtime.recentEvents ?? [], - ), - }, - null, - ); + const progress = buildGameChatProgressEvidence(runtime, {}, null); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -5892,16 +5706,7 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence( - runtime, - { - 'code-prototype': gameChatMainRuntimeWithEvents( - runtime, - runtime.recentEvents ?? [], - ), - }, - null, - ); + const progress = buildGameChatProgressEvidence(runtime, {}, null); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -5913,18 +5718,17 @@ export function registerProjectSupervisorSurfaceTests() { it('derives a playable revision only from a passed preview validation with a positive integer revision', () => { const runId = 'game-chat-playable-revision-run'; - const invalidPlaytest = gameChatPlayableMainRuntime({ + const invalidPlaytest = gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision: 1, updatedAt: 3000, }); const invalidEvidence = [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:failed', detail: JSON.stringify({ @@ -5935,11 +5739,10 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 8000, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · 验证未通过', detail: JSON.stringify({ @@ -5950,22 +5753,20 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 7000, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · 缺少 revision', detail: JSON.stringify({ passed: true, playtestPassed: true }), updatedAt: 6000, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · revision 非正整数', detail: JSON.stringify({ @@ -5976,11 +5777,10 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 5000, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · revision 不是整数', detail: JSON.stringify({ @@ -5998,22 +5798,21 @@ export function registerProjectSupervisorSurfaceTests() { expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': invalidPlaytest, + 'preview-playtest': invalidPlaytest, }), ).toBeNull(); - const previewPlaytest = gameChatPlayableMainRuntime({ + const previewPlaytest = gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision: 9, - updatedAt: 8000, + updatedAt: 9000, }); previewPlaytest.recentEvents?.push( gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: previewPlaytest.sessionId, runId: previewPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -6027,11 +5826,10 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 8500, }), gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: previewPlaytest.sessionId, runId: previewPlaytest.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -6047,25 +5845,23 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': previewPlaytest, + 'preview-playtest': previewPlaytest, }), - ).toEqual( - expect.objectContaining({ runId, revision: 10, validatedAt: 8800 }), - ); + ).toEqual({ runId, revision: 10, validatedAt: 8800 }); - const staleParentPlaytest = gameChatPlayableMainRuntime({ + const staleParentPlaytest = gameChatPreviewPlaytestRuntime({ parentRunId: 'different-parent-run', revision: 11, updatedAt: 11000, }); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': staleParentPlaytest, + 'preview-playtest': staleParentPlaytest, }), ).toBeNull(); const wrongSourcePlaytest = { - ...gameChatPlayableMainRuntime({ + ...gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision: 12, updatedAt: 12000, @@ -6074,22 +5870,26 @@ export function registerProjectSupervisorSurfaceTests() { }; expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': wrongSourcePlaytest, + 'preview-playtest': wrongSourcePlaytest, }), ).toBeNull(); - const higherFailedRevision = gameChatPlayableMainRuntime({ + const passedRevision = gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision: 20, updatedAt: 20000, }); - higherFailedRevision.recentEvents?.push( + const higherFailedRevision = gameChatPreviewPlaytestRuntime({ + parentRunId: runId, + revision: 21, + updatedAt: 21000, + }); + higherFailedRevision.recentEvents = [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: higherFailedRevision.sessionId, runId: higherFailedRevision.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -6101,25 +5901,25 @@ export function registerProjectSupervisorSurfaceTests() { }), updatedAt: 21000, }), - ); + ]; expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': higherFailedRevision, + 'preview-playtest-passed': passedRevision, + 'preview-playtest-higher-failed': higherFailedRevision, }), ).toBeNull(); - const sameRevisionLaterFailed = gameChatPlayableMainRuntime({ + const sameRevisionLaterFailed = gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision: 22, updatedAt: 22000, }); sameRevisionLaterFailed.recentEvents?.push( gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'preview-playtest', + taskId: 'preview-playtest', sessionId: sameRevisionLaterFailed.sessionId, runId: sameRevisionLaterFailed.runId, - source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -6134,7 +5934,7 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'code-prototype': sameRevisionLaterFailed, + 'preview-playtest': sameRevisionLaterFailed, }), ).toBeNull(); }); @@ -6291,8 +6091,8 @@ export function registerProjectSupervisorSurfaceTests() { ], }); const childRuntime = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'code-director', + taskId: 'code-director', sessionId: 'game-chat-stalled-code-session', runId: 'game-chat-stalled-code-run', source: 'agent-ready-task-scheduler', @@ -6301,8 +6101,8 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: now - 5 * 60 * 1000 - 10 * 1000, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', + agentId: 'code-director', + taskId: 'code-director', sessionId: 'game-chat-stalled-code-session', runId: 'game-chat-stalled-code-run', summary: '程序 Agent 最近进度', @@ -6439,9 +6239,9 @@ export function registerProjectSupervisorSurfaceTests() { label: 'child provider retry', runtime: baseRuntime, runtimeByAgentId: { - 'code-prototype': gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', + 'code-director': gameChatRuntimeState({ + agentId: 'code-director', + taskId: 'code-director', sessionId: 'game-chat-waiting-code-session', runId: 'game-chat-waiting-code-run', source: 'agent-ready-task-scheduler', @@ -6477,9 +6277,9 @@ export function registerProjectSupervisorSurfaceTests() { taskId: 'art-director', sessionId: 'game-chat-waiting-art-session', runId: 'game-chat-waiting-art-run', - source: 'agent-delegate', - parentAgentId: 'code-prototype', - parentRunId: 'game-chat-stalled-code-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: runId, phase: 'waiting-for-provider-retry', updatedAt: now - 10 * 1000, }); @@ -6552,7 +6352,6 @@ export function registerProjectSupervisorSurfaceTests() { taskId: 'code-director', sessionId: 'game-chat-terminal-duration-code-session', runId: 'game-chat-terminal-duration-code-run', - source: 'agent-ready-task-scheduler', eventType: 'turn.completed', status: 'completed', phase: 'completed', @@ -6564,7 +6363,7 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, - runtimeByAgentId: { 'code-prototype': childRuntime }, + runtimeByAgentId: { 'code-director': childRuntime }, }); expect(screen.getByLabelText('最新状态').textContent).toContain( '已持续 9 分 0 秒', @@ -6606,58 +6405,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); }); - it('fails closed when the game-chat surface hydrates a non-game-chat Supervisor root', () => { - const guiRuntime = gameChatRuntimeState({ - source: 'project-supervisor-gui', - recentEvents: [ - gameChatRuntimeEvent({ - summary: '不应进入 game-chat 当前投影', - updatedAt: 100, - }), - ], - }); - - renderGameChatStatus({ runtime: guiRuntime }); - - expect(screen.getByLabelText('最新状态').textContent).toContain('未运行'); - expect(screen.queryByText('不应进入 game-chat 当前投影')).toBeNull(); - expect(buildGameChatProgressEvidence(guiRuntime, {}, null)).toBeNull(); - expect(collectGameChatRuntimeEvents(guiRuntime, {})).toEqual([]); - }); - - it('keeps game-chat running while an exact descendant is still active after root terminal projection', () => { - const root = gameChatRuntimeState({ - runId: 'terminal-root-active-main', - status: 'completed', - phase: 'completed', - updatedAt: 200, - }); - const main = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'terminal-root-active-main-session', - runId: 'terminal-root-active-main-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: root.runId, - status: 'running', - phase: 'waiting-for-delegate-receipts', - currentAction: '等待动态美术回执', - updatedAt: 201, - }); - - renderGameChatStatus({ - runtime: root, - runtimeByAgentId: { 'code-prototype': main }, - }); - - const status = screen.getByLabelText('最新状态').textContent ?? ''; - expect(status).toContain('运行中'); - expect(status).toContain('主阶段 0/1'); - expect(status).toContain('正在等待专业 Agent 回执'); - expect(status).not.toContain('本轮已完成'); - }); - it('shows and archives the explicit mud point interruption from a failed art child runtime', () => { const rootRunId = 'game-chat-mud-point-root-run'; const runtime = gameChatRuntimeState({ @@ -6667,36 +6414,21 @@ export function registerProjectSupervisorSurfaceTests() { error: '专业 Agent 执行失败', updatedAt: 9100, }); - const mainRuntime = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'game-chat-mud-point-main-session', - runId: 'game-chat-mud-point-main-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: rootRunId, - status: 'failed', - phase: 'failed', - updatedAt: 9050, - }); const artRuntime = gameChatRuntimeState({ agentId: 'art-asset-plan', taskId: 'art-asset-plan', sessionId: 'game-chat-mud-point-art-session', runId: 'game-chat-mud-point-art-run', source: 'agent-delegate', - parentAgentId: 'code-prototype', - parentRunId: mainRuntime.runId, + parentAgentId: 'project-supervisor', + parentRunId: rootRunId, status: 'failed', phase: 'failed', error: '平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id', updatedAt: 9000, }); - const runtimeByAgentId = { - 'code-prototype': mainRuntime, - 'art-asset-plan': artRuntime, - }; + const runtimeByAgentId = { 'art-asset-plan': artRuntime }; renderGameChatStatus({ runtime, runtimeByAgentId }); @@ -6742,13 +6474,13 @@ export function registerProjectSupervisorSurfaceTests() { expect(stageRecord).not.toContain('本轮失败'); }); - it('counts only the current single main stage in game-chat progress', () => { + it('counts only the seven first-playable tasks in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', 'game-chat-progress-total', ); manifest.tasks = manifest.tasks.map((task) => - task.id === 'code-prototype' + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -6758,43 +6490,23 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'execution', updatedAt: 9000, }); - const mainRuntime = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'game-chat-progress-main-session', - runId: 'game-chat-progress-main-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: runtime.runId, - status: 'completed', - phase: 'completed', - updatedAt: 8999, - }); - renderGameChatStatus({ - runtime, - runtimeByAgentId: { 'code-prototype': mainRuntime }, - manifest, - }); + renderGameChatStatus({ runtime, manifest }); const status = screen.getByLabelText('最新状态'); - expect(status.textContent).toContain('主阶段 1/1'); + expect(status.textContent).toContain('任务图 7/7'); expect(status.textContent).not.toContain('publish-strategy'); expect(status.textContent).not.toContain('publish-package'); fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = screen.getByLabelText('Supervisor 进度播报'); - expect(progress.textContent).toContain('主阶段 1/1'); + expect(progress.textContent).toContain('任务图 7/7'); }); it('keeps one live progress card while persisting every public game-chat output as a message', async () => { const projectPath = '/tmp/game-chat-progress-broadcast'; const supervisorRunId = 'game-chat-progress-run'; const previewFailure = gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'code-prototype-progress-session', - runId: 'code-prototype-progress-run', - source: 'agent-ready-task-scheduler', + runId: supervisorRunId, eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -6827,7 +6539,7 @@ export function registerProjectSupervisorSurfaceTests() { { index: 2, title: '修复并复测', status: 'pending' }, ], activePlanStepIndex: 1, - recentEvents: [], + recentEvents: [previewFailure], updatedAt: 7200, }; let professionalRuntimes: Array> = []; @@ -6861,7 +6573,6 @@ export function registerProjectSupervisorSurfaceTests() { }, ], activePlanStepIndex: 0, - recentEvents: [previewFailure], updatedAt: 7300, }) as AgentRuntimeState; professionalRuntimes = [codePrototypeRuntime]; @@ -6961,7 +6672,7 @@ export function registerProjectSupervisorSurfaceTests() { ); const status = await screen.findByLabelText('最新状态'); - expect(status.textContent).toContain('主阶段 0/1 · 计划 1/3'); + expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3'); expect(status.textContent).toContain('核对首版试玩诊断'); expect(status.textContent).toContain('1 个专业 Agent 活跃'); const runtimeEventAppends = () => @@ -6981,7 +6692,9 @@ export function registerProjectSupervisorSurfaceTests() { expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId); expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); - expect(within(progress).getByText('主阶段 0/1 · 计划 1/3')).not.toBeNull(); + expect( + within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 1/3'), + ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); expect( @@ -6994,11 +6707,7 @@ export function registerProjectSupervisorSurfaceTests() { ), ).not.toBeNull(); const delegateDecision = gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: codePrototypeRuntime.sessionId, - runId: codePrototypeRuntime.runId, - source: 'agent-ready-task-scheduler', + runId: supervisorRunId, eventType: 'action', phase: 'tool-action', summary: '调用工具 agent.delegate', @@ -7036,20 +6745,13 @@ export function registerProjectSupervisorSurfaceTests() { }, }, }); - const updatedMainRuntime = { - ...codePrototypeRuntime, - recentEvents: [previewFailure, delegateDecision], - updatedAt: 7501, - }; - professionalRuntimes = [updatedMainRuntime]; - harness.emitAgentRuntime(updatedMainRuntime); }); await waitFor(() => { expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( - within(progress).getByText('主阶段 0/1 · 计划 2/3'), + within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 2/3'), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); expect(within(progress).getByText('返工决定')).not.toBeNull(); @@ -7277,168 +6979,15 @@ export function registerProjectSupervisorSurfaceTests() { ); }); - it('freezes a terminal game-chat stage before a delayed conversation refresh and fast next turn', async () => { - const projectPath = '/tmp/game-chat-stage-record-fast-next-turn'; - let professionalRuntimes: AgentRuntimeState[] = []; - const harness = createProjectSupervisorRuntimeHarness({ - projectPath, - runtimeMapLoader: async () => professionalRuntimes, - }); - const manifest = createGameCreationAppManifest( - 'game-chat-stage-record-fast-next-turn', - 'game-chat-stage-record-fast-next-turn', - ); - manifest.tasks = manifest.tasks.map((task) => - task.id === 'code-prototype' - ? { ...task, status: 'completed' as const } - : task, - ); - const conversationGate = createDeferred(); - const manifestGate = createDeferred(); - let delayConversationRefresh = false; - let delayManifestCapture = false; - let delayedConversationReads = 0; - let delayedManifestReads = 0; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'inspect_local_project_directory') { - return { - projectPath, - exists: true, - isDirectory: true, - isGameCreatorProject: true, - projectName: 'game-chat-stage-record-fast-next-turn', - recentRunStatus: null, - recentRunStopReason: null, - }; - } - if (command === 'init_local_game_project') { - return { - projectPath, - manifestPath: `${projectPath}/.agent/manifest.json`, - manifest, - }; - } - if (command === 'get_local_game_preview_status') { - return { status: 'stopped', url: null, port: null, root: null }; - } - if (command === 'get_local_game_manifest') { - if (delayManifestCapture) { - delayedManifestReads += 1; - await manifestGate.promise; - } - return manifest; - } - if (command === 'read_local_conversation' && delayConversationRefresh) { - delayedConversationReads += 1; - await conversationGate.promise; - } - return harness.invoke(command, args); - }, - ); - window.__TAURI__ = { - core: { invoke }, - event: { listen: harness.listen }, - }; - render( - React.createElement(App, { - initialProjectPath: projectPath, - projectSupervisorOnly: true, - gameChatOnly: true, - }), - ); - - const surface = await screen.findByLabelText('游戏创作聊天'); - const composer = within(surface).getByLabelText( - '项目总控对话内容', - ) as HTMLTextAreaElement; - await waitFor(() => expect(composer.disabled).toBe(false)); - fireEvent.change(composer, { target: { value: '完成旧轮' } }); - fireEvent.click(within(surface).getByRole('button', { name: '发送' })); - await waitFor(() => { - expect( - invoke.mock.calls.filter( - ([command]) => - command === 'start_game_creator_supervisor_runtime_task', - ), - ).toHaveLength(1); - }); - const firstStart = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', - ); - const firstRunId = String(firstStart?.[1]?.runId ?? ''); - const main = gameChatPlayableMainRuntime({ - parentRunId: firstRunId, - revision: 17, - updatedAt: 1700, - }); - professionalRuntimes = [main]; - act(() => harness.emitAgentRuntime(main)); - - delayConversationRefresh = true; - delayManifestCapture = true; - act(() => { - harness.emitRuntime( - harness.runtimeState({ - runId: firstRunId, - source: 'project-supervisor-game-chat', - status: 'completed', - phase: 'completed', - updatedAt: 4000, - }), - ); - }); - await waitFor(() => { - expect(delayedConversationReads).toBeGreaterThan(0); - expect(delayedManifestReads).toBeGreaterThan(0); - }); - - fireEvent.change(composer, { target: { value: '立即开始下一轮' } }); - fireEvent.click(within(surface).getByRole('button', { name: '发送' })); - await waitFor(() => expect(delayedManifestReads).toBeGreaterThan(1)); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', - ), - ).toHaveLength(1); - manifestGate.resolve(undefined); - await waitFor(() => { - expect( - invoke.mock.calls.filter( - ([command]) => - command === 'start_game_creator_supervisor_runtime_task', - ), - ).toHaveLength(2); - }); - - conversationGate.resolve(undefined); - await waitFor(() => { - expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - String( - (args?.message as { content?: string } | undefined)?.content ?? - '', - ).startsWith('【Supervisor 阶段记录】'), - ), - ).toHaveLength(1); - }); - }); - it('persists one terminal game-chat stage record and keeps it in the next round', async () => { const projectPath = '/tmp/game-chat-stage-record'; - let professionalRuntimes: AgentRuntimeState[] = []; - const harness = createProjectSupervisorRuntimeHarness({ - projectPath, - runtimeMapLoader: async () => professionalRuntimes, - }); + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); const manifest = createGameCreationAppManifest( 'game-chat-stage-record', 'game-chat-stage-record', ); manifest.tasks = manifest.tasks.map((task) => - task.id === 'code-prototype' + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -7556,28 +7105,28 @@ export function registerProjectSupervisorSurfaceTests() { ); const firstRunId = String(startCall?.[1]?.runId ?? ''); expect(firstRunId).not.toBe(''); - const mainRuntime = gameChatPlayableMainRuntime({ - parentRunId: firstRunId, - revision: 9, + const previewPassed = gameChatRuntimeEvent({ + runId: firstRunId, + eventType: 'observation', + summary: 'preview.validate:ok · 浏览器验证已通过', + detail: JSON.stringify({ + diagnosticsCount: 0, + passed: true, + playtestPassed: true, + revision: 9, + }), updatedAt: 9100, }); const rework = gameChatRuntimeEvent({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: mainRuntime.sessionId, - runId: mainRuntime.runId, - source: 'agent-ready-task-scheduler', + runId: firstRunId, eventType: 'action', summary: '调用工具 agent.delegate', detail: '根据上一版试玩诊断安排程序 Agent 完成返工', updatedAt: 9000, }); - mainRuntime.recentEvents = [rework, ...(mainRuntime.recentEvents ?? [])]; - professionalRuntimes = [mainRuntime]; const terminalRuntime = harness.runtimeState({ runId: firstRunId, runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'idle', phase: 'completed', loopIteration: 6, @@ -7587,7 +7136,7 @@ export function registerProjectSupervisorSurfaceTests() { { index: 1, title: '通过试玩', status: 'completed' }, ], activePlanStepIndex: null, - recentEvents: [], + recentEvents: [rework, previewPassed], updatedAt: 9200, }); harness.appendSupervisorMessage({ @@ -7603,7 +7152,6 @@ export function registerProjectSupervisorSurfaceTests() { throw new Error('missing game creator runtime update listener'); } act(() => { - harness.emitAgentRuntime(mainRuntime); updateHandler({ payload: { projectPath, @@ -7613,7 +7161,7 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'completed', runtime: { ...harness.runtimeResult(terminalRuntime), - recentEvents: [], + recentEvents: [rework, previewPassed], }, }, }); @@ -7662,11 +7210,7 @@ export function registerProjectSupervisorSurfaceTests() { it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => { const projectPath = '/tmp/game-chat-stage-record-manifest-race'; - let professionalRuntimes: AgentRuntimeState[] = []; - const harness = createProjectSupervisorRuntimeHarness({ - projectPath, - runtimeMapLoader: async () => professionalRuntimes, - }); + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); const pendingManifest = createGameCreationAppManifest( 'game-chat-stage-record-manifest-race', 'game-chat-stage-record-manifest-race', @@ -7676,9 +7220,18 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-manifest-race', ); terminalManifest.tasks = terminalManifest.tasks.map((task) => - task.id === 'code-prototype' + task.id === 'preview-playtest' ? { ...task, status: 'failed' as const } - : task, + : [ + 'design-director', + 'art-director', + 'art-asset-plan', + 'code-director', + 'code-prototype', + 'preview-readiness', + ].includes(task.id) + ? { ...task, status: 'completed' as const } + : task, ); let manifestReady = false; const invoke = vi.fn( @@ -7763,28 +7316,13 @@ export function registerProjectSupervisorSurfaceTests() { ); const runId = String(startCall?.[1]?.runId ?? ''); expect(runId).not.toBe(''); - const failedMain = gameChatRuntimeState({ - agentId: 'code-prototype', - taskId: 'code-prototype', - sessionId: 'manifest-race-main-session', - runId: 'manifest-race-main-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: runId, - status: 'failed', - phase: 'failed', - updatedAt: 9199, - }); - professionalRuntimes = [failedMain]; act(() => { - harness.emitAgentRuntime(failedMain); harness.emitRuntime( harness.runtimeState({ runId, - source: 'project-supervisor-game-chat', status: 'failed', phase: 'failed', - currentAction: 'code-prototype failed', + currentAction: 'preview-playtest failed', recentEvents: [ gameChatRuntimeEvent({ runId, @@ -7829,37 +7367,43 @@ export function registerProjectSupervisorSurfaceTests() { (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) ?.message?.content ?? '', ); - expect(stageRecord).toContain('主阶段失败'); + expect(stageRecord).toContain('6/7'); }); it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { const projectPath = '/tmp/game-chat-stage-record-initial-terminal'; - const runId = 'game-chat-stage-record-initial-terminal-run'; - const mainRuntime = gameChatPlayableMainRuntime({ - parentRunId: runId, - revision: 11, - updatedAt: 9100, - }); - const harness = createProjectSupervisorRuntimeHarness({ - projectPath, - runtimeMapLoader: async () => [mainRuntime], - }); + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); const manifest = createGameCreationAppManifest( 'game-chat-stage-record-initial-terminal', 'game-chat-stage-record-initial-terminal', ); manifest.tasks = manifest.tasks.map((task) => - task.id === 'code-prototype' + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); + const runId = 'game-chat-stage-record-initial-terminal-run'; const terminalRuntime = harness.runtimeState({ runId, - source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentAction: 'preview complete', - recentEvents: [], + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + status: 'completed', + phase: 'tool-observation', + summary: 'preview.validate:ok · 试玩验证已通过', + detail: JSON.stringify({ + diagnosticsCount: 0, + passed: true, + playtestPassed: true, + revision: 11, + }), + updatedAt: 9100, + }), + ], updatedAt: 9200, }); const invoke = vi.fn( @@ -7943,11 +7487,6 @@ export function registerProjectSupervisorSurfaceTests() { it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => { const projectPath = '/tmp/game-chat-stage-record-restart-hydration'; const runId = 'game-chat-stage-record-restart-run'; - const mainRuntime = gameChatPlayableMainRuntime({ - parentRunId: runId, - revision: 12, - updatedAt: 9100, - }); const terminalRuntime = gameChatRuntimeState({ sessionId: 'supervisor-session-active', runId, @@ -7961,13 +7500,13 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-restart-hydration', ); manifest.tasks = manifest.tasks.map((task) => - task.id === 'code-prototype' + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); const progress = buildGameChatProgressEvidence( terminalRuntime, - { 'code-prototype': mainRuntime }, + {}, manifest, ); if (!progress) { @@ -7981,7 +7520,6 @@ export function registerProjectSupervisorSurfaceTests() { const harness = createProjectSupervisorRuntimeHarness({ projectPath, initialRuntime: terminalRuntime, - runtimeMapLoader: async () => [mainRuntime], projectMessages: [ { role: 'assistant', @@ -8063,7 +7601,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ sessionId: terminalRuntime.sessionId, runId, - source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentAction: 'preview complete', @@ -8187,7 +7724,7 @@ export function registerProjectSupervisorSurfaceTests() { playableVersionReady = true; harness.setProjectRevision(1); - const firstPlayableRevision = gameChatPlayableMainRuntime({ + const firstPlayableRevision = gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunId, revision: 1, updatedAt: 3900, @@ -8197,7 +7734,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', currentTask: '验证首个可玩版本', @@ -8246,13 +7782,12 @@ export function registerProjectSupervisorSurfaceTests() { initialRuntime: { sessionId: 'supervisor-session-active', runId, - source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', updatedAt: 9200, }, runtimeMapLoader: async () => [ - gameChatPlayableMainRuntime({ + gameChatPreviewPlaytestRuntime({ parentRunId: runId, revision, updatedAt: 9100, @@ -8461,7 +7996,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunIds).toHaveLength(1); }); harness.setProjectRevision(1); - const firstPreviewPassed = gameChatPlayableMainRuntime({ + const firstPreviewPassed = gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunIds[0], revision: 1, updatedAt: 3900, @@ -8471,7 +8006,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[0], runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', currentTask: '验证第一版可玩原型', @@ -8501,7 +8035,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[0], runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentTask: '第一版已经完成', @@ -8538,7 +8071,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(firstFrame.src).toBe(firstFrameUrl); harness.setProjectRevision(2); - const secondPreviewPassed = gameChatPlayableMainRuntime({ + const secondPreviewPassed = gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunIds[1], revision: 2, updatedAt: 7900, @@ -8548,7 +8081,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[1], runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentTask: '第二版已经完成', @@ -8708,7 +8240,7 @@ export function registerProjectSupervisorSurfaceTests() { ); await act(async () => { driver.harness.emitAgentRuntime( - gameChatPlayableMainRuntime({ + gameChatPreviewPlaytestRuntime({ parentRunId: driver.parentRunId, revision: 1, updatedAt: 1000, @@ -8900,7 +8432,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); }); harness.setProjectRevision(3); - const playableRevision = gameChatPlayableMainRuntime({ + const playableRevision = gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunId, revision: 3, updatedAt: 3900, @@ -8910,7 +8442,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', updatedAt: 4000, @@ -8945,7 +8476,6 @@ export function registerProjectSupervisorSurfaceTests() { ); const previousIdleRuntime = harness.runtimeState({ runId: 'game-chat-previous-idle', - source: 'project-supervisor-game-chat', status: 'idle', phase: 'idle', updatedAt: 1000, @@ -9011,7 +8541,6 @@ export function registerProjectSupervisorSurfaceTests() { const state = harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: allowCompletion ? 'completed' : 'running', phase: allowCompletion ? 'completed' : 'execution', currentTask: '生成 accepted run 首版游戏', @@ -9085,7 +8614,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( - gameChatPlayableMainRuntime({ + gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunId, revision: 4, updatedAt: 4100, @@ -9211,7 +8740,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); }); harness.setProjectRevision(5); - const deniedPlayableRevision = gameChatPlayableMainRuntime({ + const deniedPlayableRevision = gameChatPreviewPlaytestRuntime({ parentRunId: acceptedRunId, revision: 5, updatedAt: 3900, @@ -9221,7 +8750,6 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', updatedAt: 4000, @@ -9594,7 +9122,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it('opens an existing project into the active Supervisor Session, restores history, then starts and steers the same run', async () => { + it('opens an existing project, restores legacy history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -9629,6 +9157,19 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } + if (command === 'chat_with_game_creator_direct_codex') { + return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + return { + url: 'http://127.0.0.1:43124/game/index.html', + port: 43124, + root: projectPath, + }; + } return supervisorHarness.invoke(command, args); }, ); @@ -9653,10 +9194,14 @@ export function registerProjectSupervisorSurfaceTests() { agentId: 'project-supervisor', sessionId: supervisorHarness.sessionId, }); - expect(screen.getByLabelText('专业 Agent 协作状态')).not.toBeNull(); + expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); + expect(screen.getByRole('status', { name: '自动执行' })).not.toBeNull(); + expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); + expect(screen.queryByText('严格审批')).toBeNull(); + expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); - expect(supervisorHarness.listen).not.toHaveBeenCalledWith( + expect(supervisorHarness.listen).toHaveBeenCalledWith( 'game-creator-agent-progress', expect.any(Function), ); @@ -9664,29 +9209,51 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; + const directReply = createDeferred(); + invoke.mockImplementation( + async (command: string, args?: Record) => { + if (command === 'chat_with_game_creator_direct_codex') { + return directReply.promise; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + return { + url: 'http://127.0.0.1:43124/game/index.html', + port: 43124, + root: projectPath, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + fireEvent.change(screen.getByLabelText('项目需求'), { target: { value: '先完成正式客户端玩法拆解' }, }); fireEvent.click( within(supervisorSurface).getByRole('button', { name: '发送' }), ); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - { - projectPath, - sessionId: supervisorHarness.sessionId, - task: '先完成正式客户端玩法拆解', - runId: expect.stringMatching(/^project-supervisor-task-/), - runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', - }, - ); - }); - const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + await waitFor(() => + expect( + within(supervisorSurface).getByText('已提交需求,正在准备智能创作'), + ).not.toBeNull(), ); - const runId = String(startCall?.[1]?.runId ?? ''); + await act(async () => { + supervisorHarness.emitProgress('art.spec', '正在生成统一视觉规范图'); + }); + expect( + within(supervisorSurface).getByText('正在生成统一视觉规范图'), + ).not.toBeNull(); + await act(async () => { + directReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + }); + expect( + await within(supervisorSurface).findByText( + 'DIRECT_REPLY:先完成正式客户端玩法拆解', + ), + ).not.toBeNull(); await waitFor(() => { expect( ( @@ -9705,16 +9272,10 @@ export function registerProjectSupervisorSurfaceTests() { ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'steer_game_creator_agent_runtime_task', + 'chat_with_game_creator_direct_codex', { projectPath, - agentId: 'project-supervisor', - sessionId: supervisorHarness.sessionId, - runId, - steerId: expect.stringMatching(/^project-supervisor-steer-/), - instruction: '补充:优先复用现有素材', - runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', + prompt: '补充:优先复用现有素材', }, ); }); @@ -9722,15 +9283,25 @@ export function registerProjectSupervisorSurfaceTests() { invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', ), - ).toHaveLength(1); + ).toHaveLength(0); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'steer_game_creator_agent_runtime_task', + ), + ).toHaveLength(0); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(2); expect( invoke.mock.calls.filter( ([command]) => command === 'read_project_permission_policy', ), - ).toHaveLength(policyReadCountBeforeChat); + ).toHaveLength(policyReadCountBeforeChat + 1); }); - it('keeps the workbench professional Agent status truthful through polling without Tauri events', async () => { + it('keeps legacy professional Agent results readable while runtime controls stay hidden', async () => { const projectPath = '/tmp/launcher-runtime-status-game'; const supervisorRunId = 'supervisor-runtime-status-run'; const manifest = createGameCreationAppManifest( @@ -9745,11 +9316,8 @@ export function registerProjectSupervisorSurfaceTests() { let failProfessionalRuntimeReads = false; let failProfessionalConversationReads = false; let designConversationMode: 'initial' | 'updated' = 'initial'; - let failProfessionalAction = false; - let failProfessionalRetry = true; let professionalRuntimeReadCount = 0; let professionalConversationReadCount = 0; - let releaseProfessionalRetry: (() => void) | null = null; let professionalRuntimes: Array> = []; const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, @@ -9989,76 +9557,6 @@ export function registerProjectSupervisorSurfaceTests() { ], }; } - if (command === 'confirm_retry_game_creator_agent_runtime_task') { - if (failProfessionalRetry) { - throw new Error( - 'agentLlm.design-director retry失败:kind=transport fingerprint=private-retry-fingerprint chars=256', - ); - } - await new Promise((resolve) => { - releaseProfessionalRetry = resolve; - }); - const retryRunId = String(args?.nextRunId ?? ''); - const staleFailedResult = supervisorHarness.runtimeResult( - professionalRuntimes[0] as AgentRuntimeState, - ); - return { - ...staleFailedResult, - acceptedRunId: retryRunId, - taskQueue: { - ...staleFailedResult.taskQueue, - pending: 1, - latestRunId: retryRunId, - updatedAt: 7000, - }, - recentTasks: [ - { - schemaVersion: 'game-creator-agent-runtime-task.v1', - agentId: 'design-director', - taskId: 'design-director', - sessionId: 'design-runtime-status-session', - runId: 'design-runtime-status-run', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'design-runtime-status-delegation', - task: '拆解首版玩法', - status: 'failed', - phase: 'failed', - currentAction: '策划 Runtime 已失败', - terminalDetail: 'kind=budget-exhausted', - error: 'kind=budget-exhausted', - updatedAt: 6100, - }, - { - schemaVersion: 'game-creator-agent-runtime-task.v1', - agentId: 'design-director', - taskId: 'design-director', - sessionId: 'design-runtime-status-session', - runId: retryRunId, - source: 'agent-delegate-retry', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'design-runtime-retry-delegation', - task: '拆解首版玩法', - status: 'pending', - phase: 'queued', - currentAction: '等待当前后台任务完成', - terminalDetail: null, - error: null, - updatedAt: 7000, - }, - ], - }; - } - if ( - failProfessionalAction && - command === 'confirm_game_creator_agent_runtime_task' - ) { - throw new Error( - 'agentLlm.art-director 工具确认失败:kind=transport fingerprint=private-action-fingerprint chars=384', - ); - } return supervisorHarness.invoke(command, args); }, ); @@ -10072,61 +9570,52 @@ export function registerProjectSupervisorSurfaceTests() { expect(professionalRuntimeReadCount).toBeGreaterThan(0); }); expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull(); - const supervisorStatusPanel = screen.getByLabelText('项目总控 Agent 状态'); - supervisorStatusPanel.scrollTop = 120; + expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull(); + expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); const readCountBeforeExpose = professionalRuntimeReadCount; exposeProfessionalRuntimes = true; - const professionalList = await screen.findByLabelText( - '专业 Agent 实时状态', - {}, + await waitFor( + () => { + expect(professionalRuntimeReadCount).toBeGreaterThan( + readCountBeforeExpose, + ); + }, { timeout: 2500 }, ); - expect(professionalRuntimeReadCount).toBeGreaterThan(readCountBeforeExpose); - expect( - within(professionalList).getByText( - '设计实现 Agent 服务连接失败,请稍后重试', - ), - ).not.toBeNull(); - expect(professionalList.textContent).not.toContain('fingerprint'); - expect(professionalList.textContent).not.toContain('chars=545'); - expect(professionalList.textContent).not.toContain('contentChars'); - expect(supervisorStatusPanel.scrollTop).toBe(0); - expect( - within(professionalList).getByText('等待确认后继续工作'), - ).not.toBeNull(); - expect(within(professionalList).getByText('本轮工作已完成')).not.toBeNull(); - expect( - within(professionalList).queryByText('旧父 Run 的状态不得显示'), - ).toBeNull(); + expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull(); + expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull(); + expect(screen.queryByText('旧父 Run 的状态不得显示')).toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('read_local_conversation', { projectPath, agentId: 'code-director', }); }); - const codeAgentCard = within(professionalList) - .getByText('程序 Agent') - .closest('article')!; - const resultButton = await within(codeAgentCard).findByRole('button', { - name: '查看成果', - }); - fireEvent.click(resultButton); - const resultDialog = await screen.findByRole('dialog', { + const documentResources = screen.getByLabelText('文档'); + const codeReceiptCard = await within(documentResources).findByRole( + 'button', + { + name: '打开资源详情:文档 程序 Agent 文本回执', + }, + ); + fireEvent.click(codeReceiptCard); + const codeReceiptFocus = await screen.findByRole('region', { name: '程序 Agent 文本回执', }); - expect(resultDialog.textContent).toContain( + expect(codeReceiptFocus.textContent).toContain( '已完成程序实现拆解,并给出可执行的模块边界。', ); - fireEvent.click(within(resultDialog).getByRole('button', { name: '关闭' })); - const documentResources = screen.getByLabelText('文档'); + fireEvent.click( + within(codeReceiptFocus).getByRole('button', { name: '收起资源' }), + ); expect( within(documentResources).getByRole('button', { name: '打开资源详情:文档 程序 Agent 文本回执', }), ).not.toBeNull(); const designReceiptCard = within(documentResources).getByRole('button', { - name: '打开资源详情:文档 设计实现 Agent 文本回执', + name: '打开资源详情:文档 策划 Agent 文本回执', }); expect(designReceiptCard).not.toBeNull(); expect( @@ -10142,16 +9631,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(documentResources.textContent).not.toContain( '当前新一轮策划失败,不得覆盖旧成果。', ); - fireEvent.click(designReceiptCard); - const designReceiptFocus = await screen.findByRole('region', { - name: '设计实现 Agent 文本回执', - }); - expect( - within(designReceiptFocus).getByText('历史成果 · 设计实现 Agent'), - ).not.toBeNull(); - fireEvent.click( - within(designReceiptFocus).getByRole('button', { name: '收起资源' }), - ); failProfessionalConversationReads = true; const conversationReadCountBeforeFailure = @@ -10168,7 +9647,7 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( within(screen.getByLabelText('文档')).getByRole('button', { - name: '打开资源详情:文档 设计实现 Agent 文本回执', + name: '打开资源详情:文档 策划 Agent 文本回执', }), ).not.toBeNull(); expect( @@ -10197,7 +9676,7 @@ export function registerProjectSupervisorSurfaceTests() { ); const updatedDesignCard = await waitFor(() => { const card = within(screen.getByLabelText('文档')).getByRole('button', { - name: '打开资源详情:文档 设计实现 Agent 文本回执', + name: '打开资源详情:文档 策划 Agent 文本回执', }); expect(card.getAttribute('data-resource-id')).toContain( 'agent-finalization-33333333333333333333333333333333', @@ -10206,7 +9685,7 @@ export function registerProjectSupervisorSurfaceTests() { }); fireEvent.click(updatedDesignCard); const updatedDesignDialog = await screen.findByRole('region', { - name: '设计实现 Agent 文本回执', + name: '策划 Agent 文本回执', }); expect( within(updatedDesignDialog).getByText( @@ -10217,51 +9696,20 @@ export function registerProjectSupervisorSurfaceTests() { within(updatedDesignDialog).getByRole('button', { name: '收起资源' }), ); - const dock = screen.getByLabelText('子 Agent 状态栏'); - await waitFor(() => { - expect( - within( - within(dock).getByRole('article', { name: /设计实现 Agent/ }), - ).getByText('设计实现 Agent 服务连接失败,请稍后重试'), - ).not.toBeNull(); - expect( - within( - within(dock).getByRole('article', { name: /美术 Agent/ }), - ).getByText('待确认'), - ).not.toBeNull(); - expect( - within( - within(dock).getByRole('article', { name: /程序 Agent/ }), - ).getByText('已完成'), - ).not.toBeNull(); - }); - - const artPendingAction = - within(professionalList).getByLabelText('美术 Agent待确认动作'); - failProfessionalAction = true; - fireEvent.click( - within(artPendingAction).getByRole('button', { name: '确认' }), - ); + expect(screen.queryByLabelText('美术 Agent待确认动作')).toBeNull(); expect( - await screen.findByText('专业 Agent 服务连接失败,请稍后重试'), - ).not.toBeNull(); - expect(screen.queryByText(/private-action-fingerprint/)).toBeNull(); - failProfessionalAction = false; - fireEvent.click( - within(artPendingAction).getByRole('button', { name: '确认' }), + screen.queryByRole('button', { + name: '在当前项目重试', + }), + ).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'confirm_game_creator_agent_runtime_task', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + expect.anything(), ); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'confirm_game_creator_agent_runtime_task', - { - projectPath, - agentId: 'art-director', - runId: 'art-runtime-status-run', - actionId: 'art-file-write-action', - note: '用户已确认专业 Agent 待执行工具动作', - }, - ); - }); failProfessionalRuntimeReads = true; const readCountBeforeFailure = professionalRuntimeReadCount; @@ -10273,74 +9721,8 @@ export function registerProjectSupervisorSurfaceTests() { }, { timeout: 2500 }, ); - expect( - within( - within(dock).getByRole('article', { name: /设计实现 Agent/ }), - ).getByText('设计实现 Agent 服务连接失败,请稍后重试'), - ).not.toBeNull(); - expect( - within( - within(dock).getByRole('article', { name: /美术 Agent/ }), - ).getByText('待确认'), - ).not.toBeNull(); - expect( - within( - within(dock).getByRole('article', { name: /程序 Agent/ }), - ).getByText('已完成'), - ).not.toBeNull(); - - let retryButton = within(professionalList).getByRole('button', { - name: '在当前项目重试', - }); - fireEvent.click(retryButton); - expect( - await within(retryButton.closest('article')!).findByText( - '设计实现 Agent 服务连接失败,请稍后重试', - ), - ).not.toBeNull(); - expect(retryButton.closest('article')?.textContent).not.toContain( - 'private-retry-fingerprint', - ); - failProfessionalRetry = false; - retryButton = within(professionalList).getByRole('button', { - name: '在当前项目重试', - }); - fireEvent.click(retryButton); - const retryingButton = within(professionalList).getByRole('button', { - name: '正在提交重试…', - }); - expect(retryingButton.hasAttribute('disabled')).toBe(true); - expect( - within(retryingButton.closest('article')!).getByRole('status') - .textContent, - ).toBe('正在提交重试…'); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'confirm_retry_game_creator_agent_runtime_task', - { - projectPath, - agentId: 'design-director', - runId: 'design-runtime-status-run', - nextRunId: expect.stringMatching(/^project-professional-retry-/), - }, - ); - }); - releaseProfessionalRetry?.(); - await within(professionalList).findByText( - '重试请求已受理,正在同步新一轮状态', - ); - const retriedDesignCard = within(professionalList) - .getByText('设计实现 Agent') - .closest('article')!; - expect(retriedDesignCard.textContent).not.toContain( - '设计实现 Agent 服务连接失败,请稍后重试', - ); - expect(retriedDesignCard.textContent).toContain('执行中'); - expect( - within(retriedDesignCard).queryByRole('button', { - name: '在当前项目重试', - }), - ).toBeNull(); + expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull(); + expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull(); failProfessionalRuntimeReads = false; exposeProfessionalRuntimes = false; @@ -10351,14 +9733,10 @@ export function registerProjectSupervisorSurfaceTests() { readCountBeforeAuthoritativeEmpty, ); expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull(); - expect( - within( - within(dock).getByRole('article', { name: /设计实现 Agent/ }), - ).getByText('等待中'), - ).not.toBeNull(); + expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect( within(screen.getByLabelText('文档')).getByRole('button', { - name: '打开资源详情:文档 设计实现 Agent 文本回执', + name: '打开资源详情:文档 策划 Agent 文本回执', }), ).not.toBeNull(); }, @@ -10455,7 +9833,7 @@ export function registerProjectSupervisorSurfaceTests() { ); }); - it('opens a selected Godot project as the active root and keeps Supervisor composer keyboard semantics', async () => { + it('opens a selected Godot project as the active root and keeps direct Codex composer keyboard semantics', async () => { const projectPath = '/tmp/existing-godot-project'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -10490,6 +9868,19 @@ export function registerProjectSupervisorSurfaceTests() { manifest, }; } + if (command === 'chat_with_game_creator_direct_codex') { + return 'DIRECT_GODOT_OK'; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + return { + url: 'http://127.0.0.1:43125/game/index.html', + port: 43125, + root: projectPath, + }; + } return supervisorHarness.invoke(command, args); }, ); @@ -10504,7 +9895,7 @@ export function registerProjectSupervisorSurfaceTests() { const surface = await screen.findByLabelText('项目总控对话'); expect(invoke).toHaveBeenCalledWith('import_local_godot_project', { projectPath, - projectId: expect.stringMatching(/^local-project-/), + projectId: 'local-project-draft', name: 'existing-godot-project', }); expect( @@ -10520,7 +9911,7 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( invoke.mock.calls.some( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toBe(false); @@ -10529,21 +9920,24 @@ export function registerProjectSupervisorSurfaceTests() { ).toBe(true); expect( invoke.mock.calls.some( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toBe(false); expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - expect.objectContaining({ + 'chat_with_game_creator_direct_codex', + { projectPath, - runProfile: 'standard', - task: '修改玩家移动脚本', - }), + prompt: '修改玩家移动脚本', + }, ); }); + expect(invoke).not.toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.anything(), + ); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 6147c0fe4..bb19d6b30 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -237,7 +237,7 @@ export function registerAgentStatusDerivationTests() { } export function registerRuntimeSettingsTests() { - it('switches all Agent modes and exposes effective LLM config for app-server', async () => { + it('keeps the Agent mode fixed to app-server and preserves its LLM config', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_game_creator_app_config') { @@ -282,22 +282,12 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - const mode = await screen.findByLabelText('Agent 模式'); - expect(mode).toHaveProperty('value', 'codex_app_server'); + expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(screen.queryByLabelText('Agent 模式')).toBeNull(); expect(screen.getByLabelText('LLM 模型')).toHaveProperty( 'value', 'preserved-provider-model', ); - - fireEvent.change(mode, { target: { value: 'codex_cli' } }); - expect(mode).toHaveProperty('value', 'codex_cli'); - expect(screen.queryByLabelText('LLM 模型')).toBeNull(); - fireEvent.change(mode, { target: { value: 'provider' } }); - expect(screen.getByLabelText('LLM 模型')).toHaveProperty( - 'value', - 'preserved-provider-model', - ); - fireEvent.change(mode, { target: { value: 'codex_app_server' } }); fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { @@ -313,7 +303,7 @@ export function registerRuntimeSettingsTests() { }); }); - it('preserves an unknown Agent mode returned by a newer runtime', async () => { + it('migrates an unknown Agent mode to the fixed app-server mode on save', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_game_creator_app_config') { @@ -358,16 +348,14 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect(await screen.findByLabelText('Agent 模式')).toHaveProperty( - 'value', - 'future_runtime_mode', - ); + expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(screen.queryByLabelText('Agent 模式')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { config: expect.objectContaining({ - agentMode: 'future_runtime_mode', + agentMode: 'codex_app_server', }), }); }); @@ -468,6 +456,44 @@ export function registerRuntimeSettingsTests() { expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull(); }); + it('locks page scrolling while runtime settings are open and restores it after close', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-scroll-lock', + apiKind: 'openai_responses', + stream: true, + webSearchEnabled: false, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + document.documentElement.style.overflow = 'auto'; + document.body.style.overflow = 'scroll'; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + await screen.findByRole('dialog', { name: '运行时配置' }); + expect(document.documentElement.style.overflow).toBe('hidden'); + expect(document.body.style.overflow).toBe('hidden'); + + fireEvent.click(screen.getByRole('button', { name: '关闭 Agent 设置' })); + expect(document.documentElement.style.overflow).toBe('auto'); + expect(document.body.style.overflow).toBe('scroll'); + }); + it('configures MCP servers and reads the bounded Runner catalog', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -888,15 +914,8 @@ export function registerPublishedRuntimeSettingsTests() { screen.getByLabelText('LLM API Key').getAttribute('autocomplete'), ).toBe('off'); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( - 'type', - 'password', - ); - expect( - screen - .getByLabelText('External Editor API Key') - .getAttribute('autocomplete'), - ).toBe('off'); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /Agent 模型/ })); fireEvent.click(screen.getByRole('button', { name: /Planner planner/ })); fireEvent.click( @@ -940,6 +959,12 @@ export function registerPublishedRuntimeSettingsTests() { 'value', '', ); + expect( + within(screen.getByLabelText('Generator LLM 推理档')).getByRole( + 'option', + { name: 'max' }, + ), + ).not.toBeNull(); expect( screen.getByLabelText('生成首版美术素材 (art/Asset) LLM 推理档'), ).toHaveProperty('value', ''); @@ -997,6 +1022,11 @@ export function registerPublishedRuntimeSettingsTests() { 'value', 'medium', ); + expect( + within(screen.getByLabelText('LLM 推理档')).getByRole('option', { + name: 'max', + }), + ).not.toBeNull(); fireEvent.change(screen.getByLabelText('LLM API Key'), { target: { value: 'unit-new-secret-value' }, }); @@ -1059,21 +1089,20 @@ export function registerPublishedRuntimeSettingsTests() { }, ); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - fireEvent.change(screen.getByLabelText('External Editor Base URL'), { - target: { value: 'http://127.0.0.1:8099' }, - }); - fireEvent.change(screen.getByLabelText('External Editor API Key'), { - target: { value: 'editor-new-secret' }, - }); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); expect(await screen.findByText(/已保存:/)).not.toBeNull(); + expect( + (await screen.findByLabelText('运行时配置提示')).textContent, + ).toContain('保存成功'); expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'); expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { config: { - agentMode: 'provider', + agentMode: 'codex_app_server', llm: { apiKey: 'unit-new-secret-value', baseUrl: 'https://new-llm.example.test/v1', @@ -1123,12 +1152,15 @@ export function registerPublishedRuntimeSettingsTests() { }, }, editorApi: { - baseUrl: 'http://127.0.0.1:8099', - apiKey: 'editor-new-secret', + baseUrl: 'https://dev.genarrative.world', + apiKey: '', }, mcpServers: {}, }, }); + expect(JSON.stringify(persistedConfig)).not.toContain( + 'editor-loaded-secret', + ); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '读取' }), ); @@ -1182,23 +1214,18 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.getByText('已恢复默认配置,保存后生效')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); - expect(screen.getByLabelText('Agent 模式')).toHaveProperty( - 'value', - 'codex_app_server', - ); - fireEvent.change(screen.getByLabelText('Agent 模式'), { - target: { value: 'provider' }, - }); + expect(screen.getByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(screen.queryByLabelText('Agent 模式')).toBeNull(); expect(screen.getByLabelText('LLM API Key')).toHaveProperty('value', ''); expect(screen.getByLabelText('LLM Base URL')).toHaveProperty( 'value', - 'https://api.openai.com/v1', + 'https://dev.genarrative.world/gpt/v1', ); expect(screen.getByLabelText('LLM 模型')).toHaveProperty( 'value', - 'gpt-4.1', + 'gpt-5.6-sol', ); - expect(screen.getByLabelText('LLM 推理档')).toHaveProperty('value', 'high'); + expect(screen.getByLabelText('LLM 推理档')).toHaveProperty('value', 'max'); expect(screen.getByLabelText('LLM 联网检索')).toHaveProperty( 'checked', false, @@ -1222,10 +1249,8 @@ export function registerPublishedRuntimeSettingsTests() { '12000', ); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( - 'value', - 'http://127.0.0.1:8082', - ); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); @@ -1233,14 +1258,14 @@ export function registerPublishedRuntimeSettingsTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { config: { - agentMode: 'provider', + agentMode: 'codex_app_server', llm: { apiKey: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4.1', + baseUrl: 'https://dev.genarrative.world/gpt/v1', + model: 'gpt-5.6-sol', apiKind: 'openai_responses', - reasoningEffort: 'high', - stream: false, + reasoningEffort: 'max', + stream: true, webSearchEnabled: false, contextWindowTokens: 128000, autoCompactTokenLimit: 64000, @@ -1251,7 +1276,7 @@ export function registerPublishedRuntimeSettingsTests() { }, agentLlm: {}, editorApi: { - baseUrl: 'http://127.0.0.1:8082', + baseUrl: 'https://dev.genarrative.world', apiKey: '', }, mcpServers: {}, @@ -1308,6 +1333,9 @@ export function registerPublishedRuntimeSettingsTests() { expect( await screen.findByText('Anthropic 当前不支持 Provider 原生联网检索'), ).not.toBeNull(); + expect( + (await screen.findByLabelText('运行时配置提示')).textContent, + ).toContain('保存失败:Anthropic 当前不支持 Provider 原生联网检索'); expect(screen.getByLabelText('LLM Provider')).toHaveProperty( 'value', 'anthropic', diff --git a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts index 6dd7b03a1..233af074f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts @@ -1382,7 +1382,7 @@ export function registerSupervisorRuntimeTests() { 'run: 实时状态不可用:core:event:allow-listen denied', ), ).not.toBeNull(); - expect(screen.getByText('AI 游戏创作')).not.toBeNull(); + expect(screen.getByText('陶泥儿')).not.toBeNull(); expect(listen).toHaveBeenCalledWith( 'game-creator-agent-progress', expect.any(Function), diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 28bc88819..53be11dd0 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -37,6 +37,11 @@ import { clearStoredAuthAccessToken, setStoredAuthAccessToken, } from '../src/services/clientApi'; +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + resetPlatformSessionStateForTests, +} from '../src/services/platformSession'; class TestPointerEvent extends MouseEvent { readonly pointerId: number; @@ -579,6 +584,7 @@ function expectLockedBackgroundCannotCallPaidPorts( } beforeEach(() => { + resetPlatformSessionStateForTests(); clearStoredAuthAccessToken(); Object.defineProperty(window, 'PointerEvent', { configurable: true, @@ -595,7 +601,9 @@ beforeEach(() => { }); afterEach(() => { + resetPlatformSessionStateForTests(); clearStoredAuthAccessToken(); + delete window.__TAURI__; vi.restoreAllMocks(); }); @@ -2654,4 +2662,123 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect(confirmationInput).not.toHaveProperty('apiKey'); expect(invokeSpy).toHaveBeenCalledTimes(3); }); + + it('Tauri adapter 在 401 后刷新内存登录态并只重放原生成命令一次', async () => { + const accessToken = 'expired-platform-token'; + setStoredAuthAccessToken(accessToken); + const installInvoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke: installInvoke } }; + const authUser = { + id: 'user-test', + publicUserCode: 'tn-test', + displayName: '测试用户', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: '138****0000', + loginMethod: 'password' as const, + bindingStatus: 'active' as const, + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, + }; + const generation = beginPlatformSessionTransition(); + await commitAuthenticatedPlatformSession(authUser, generation); + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'refreshed-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: authUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + const manifest = manifestFixture(scope.projectId); + let generationCalls = 0; + const invokeSpy = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'generate_local_project_asset_canvas_image') { + throw new Error(`unexpected command: ${command}`); + } + generationCalls += 1; + if (generationCalls === 1) { + throw new Error('authentication-required: 登录已失效'); + } + return { + generation: { + generationId: '22222222-2222-4222-8222-222222222222', + intentId: '11111111-1111-4111-8111-111111111111', + phase: 'asset-durable-committed', + referenceResourceIds: [], + outputAssetId: 'canvas-commit', + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + images: [], + commit: { + resourceId: 'local-asset:canvas-commit', + assetId: 'canvas-commit', + projectId: scope.projectId, + commitId: '44444444-4444-4444-8444-444444444444', + committedProjectRevision: 1, + draftRevision: 1, + hostRevision: '1', + commitStatus: 'committed', + manifest, + eventId: 'event-auth-refresh', + }, + requestWasTransient: Boolean(args), + }; + }, + ); + const adapter = createTauriImageCanvasHostAdapter({ + projectPath: '/fixture/project', + expectedProjectId: scope.projectId, + expectedHostRevision: 0, + invoke: invokeSpy as unknown as ( + command: string, + args?: Record, + ) => Promise, + }); + const input = { + scope, + expectedHostRevision: '0', + expectedDraftRevision: 0, + intentId: '11111111-1111-4111-8111-111111111111', + generationId: '22222222-2222-4222-8222-222222222222', + idempotencyKey: '33333333-3333-4333-8333-333333333333', + commitId: '44444444-4444-4444-8444-444444444444', + commitIdempotencyKey: '55555555-5555-4555-8555-555555555555', + prompt: '生成主角', + aspectRatio: '1:1', + imageSize: '1K', + assetKind: 'illustration', + assetName: '主角', + referenceResourceIds: [], + }; + + await expect(adapter.generation.generateImage(input)).resolves.toEqual( + expect.objectContaining({ status: 'ok' }), + ); + expect(invokeSpy).toHaveBeenCalledTimes(2); + expect(invokeSpy.mock.calls[0]?.[1]).toEqual(invokeSpy.mock.calls[1]?.[1]); + expect(installInvoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: authUser.id, + accessToken: 'refreshed-token', + }), + ); + }); }); diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index daecda81d..8b6114d3a 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { AGC_DEVELOPMENT_API_BASE_URL, + AGC_RELEASE_API_BASE_URL, + getClientServerBaseUrl, + getClientServerSelection, + normalizeClientServerBaseUrl, + resetClientServerSelectionForTests, resolveClientHttpTarget, + setClientServerSelection, } from '../src/services/clientHttp'; describe('AGC client HTTP transport', () => { + afterEach(() => resetClientServerSelectionForTests()); + it('keeps local development requests on the Vite API proxy', () => { expect( resolveClientHttpTarget('/api/auth/me', { @@ -22,10 +30,12 @@ describe('AGC client HTTP transport', () => { isDevelopment: false, isTauri: true, pageProtocol: 'tauri:', + mode: 'production', + serverBaseUrl: AGC_RELEASE_API_BASE_URL, }), ).toEqual({ transport: 'tauri-http', - url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, + url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`, }); }); @@ -35,6 +45,7 @@ describe('AGC client HTTP transport', () => { isDevelopment: false, isTauri: false, pageProtocol: 'https:', + mode: 'test', }), ).toEqual({ transport: 'web', url: '/api/auth/me' }); }); @@ -45,7 +56,92 @@ describe('AGC client HTTP transport', () => { isDevelopment: false, isTauri: true, pageProtocol: 'tauri:', + mode: 'production', + serverBaseUrl: AGC_RELEASE_API_BASE_URL, }), - ).toThrow('不在允许的开发服务器范围内'); + ).toThrow('当前选择的服务器范围'); + }); + + it('persists release, dev, and custom server selection', () => { + const release = setClientServerSelection({ + preset: 'release', + customBaseUrl: '', + }); + expect(release).toEqual({ + preset: 'release', + customBaseUrl: '', + }); + expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL); + const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); + expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL); + + const custom = setClientServerSelection({ + preset: 'custom', + customBaseUrl: 'https://staging.example.com/', + }); + expect(custom).toEqual({ + preset: 'custom', + customBaseUrl: 'https://staging.example.com', + }); + expect(getClientServerSelection().preset).toBe('dev'); + expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com'); + }); + + it('accepts HTTPS custom servers and loopback HTTP only', () => { + expect(normalizeClientServerBaseUrl('https://example.com/')).toBe( + 'https://example.com', + ); + expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe( + 'http://127.0.0.1:8080', + ); + expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow( + '必须使用 HTTPS', + ); + expect(() => + normalizeClientServerBaseUrl('https://example.com/api'), + ).toThrow('纯 HTTP(S)'); + }); + + it('routes selected custom servers for both web and Tauri clients', () => { + const serverBaseUrl = 'https://staging.example.com'; + expect( + resolveClientHttpTarget('/api/auth/me', { + isDevelopment: true, + isTauri: false, + pageProtocol: 'http:', + mode: 'development', + serverBaseUrl, + }), + ).toEqual({ + transport: 'web', + url: `${serverBaseUrl}/api/auth/me`, + }); + expect( + resolveClientHttpTarget('/api/auth/me', { + isDevelopment: false, + isTauri: true, + pageProtocol: 'tauri:', + mode: 'production', + serverBaseUrl, + }), + ).toEqual({ + transport: 'tauri-http', + url: `${serverBaseUrl}/api/auth/me`, + }); + }); + + it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => { + expect( + resolveClientHttpTarget('/api/auth/me', { + isDevelopment: false, + isTauri: true, + pageProtocol: 'http:', + mode: 'production', + serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }), + ).toEqual({ + transport: 'tauri-http', + url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 13f929ef2..92ef7f361 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -27,6 +27,13 @@ describe('项目资源投影', () => { audioTask.status = 'completed'; audioTask.artifacts = ['audio/unregistered-bgm.wav']; manifest.assets = [ + { + id: 'game-entry', + kind: 'game-entry', + mediaType: 'text/html', + localPath: 'game/index.html', + source: { kind: 'generated' }, + }, { id: 'registered-bgm', kind: 'bgm', @@ -102,6 +109,7 @@ describe('项目资源投影', () => { id: 'task:design-foundation:memory/game-design.md', category: 'document', }, + { id: 'asset:game-entry', category: 'code' }, { id: 'task:design-foundation:assets/ui-preview.svg', category: 'art', diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 4b0cad61f..881129c9f 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -263,7 +263,7 @@ describe('AI 游戏创作动态端口启动前预检', () => { }; const agcHtml = { statusCode: 200, - body: 'AI 游戏创作', + body: '陶泥儿', }; test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 53e6adb70..73a26becd 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -100,6 +100,36 @@ afterEach(() => { }); describe('useProjectResourceCardPreviews', () => { + it('prefetches initial previewable resources without requiring a detail click', async () => { + const art = resource('initial-art'); + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? art.path)), + ); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-prefetch', + projectId: 'preview-prefetch', + mode: 'dependency', + resources: [art], + canvasRef, + eagerPreviewLimit: 12, + }), + ); + const identity = result.current.identityByResourceId.get(art.id)!; + + await waitFor(() => + expect(result.current.previews.get(identity)?.status).toBe('loaded'), + ); + expect(previewReadCalls(invoke)).toHaveLength(1); + expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({ + relativePath: art.path, + }); + }); + it('only treats the exact native cancellation category as cancellation', () => { const cancellation = 'project-resource-preview-scope-cancelled'; expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true); diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 63a5ead04..4cf3067aa 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3681,6 +3681,13 @@ "maxLength": 200 } }, + "sliceLayout": { + "type": "string", + "enum": [ + "grid-2x2" + ], + "description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。" + }, "screenColor": { "type": [ "string", @@ -3958,11 +3965,18 @@ }, "iconImageSrcs": { "type": "array", - "description": "识别图集中全部有效 alpha 连通域并持久化的独立素材列表,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。自动生成与手动拆分图集使用相同识别规则。", + "description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, + "sliceLayout": { + "type": "string", + "enum": [ + "grid-2x2" + ], + "description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。" + }, "sliceWarning": { "anyOf": [ { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 55b058981..d175f6a82 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -109,7 +109,7 @@ - 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。 - 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。 - 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 -- 普通用户界面不默认展示内部错误码或本机绝对路径;External Editor 配置只进入独立“运行时配置”对话框,不与素材画布主要动作并列,确需诊断的信息进入受控详情或开发模式。 +- 普通用户界面不默认展示内部错误码或本机绝对路径,也不展示 External Editor Base URL 或 Developer API Key;官方服务地址由客户端构建固定,远端编辑自动使用当前陶泥儿登录态。仅独立 standalone game-chat release 或显式高级自定义模式在隔离配置页维护 External v1 URL/Key,确需诊断的信息进入受控详情或开发模式。 - 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。 ### 3.8 现有 Godot 项目 @@ -538,7 +538,7 @@ type ProjectAgentMudPointAttribution = { ### 7.6 素材创作无限画布阶段一至五最终验收 -实现状态(2026-08-12):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 远端媒体编辑固定使用 AppData 私有 `editorApi.baseUrl/apiKey` 访问 `/api/external/v1/*`,普通 Launcher、开发工作台和独立 game-chat 的“运行时配置”都可编辑这两个字段;不把 Key 传入 WebView、项目事实、账本、日志或普通错误,素材画布工作区自身不提供凭据输入。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 +实现状态(2026-08-15):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。普通 Tauri 客户端登录陶泥儿后直接使用固定官方 origin 下的网站现役 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`,不展示或要求填写 Base URL/API Key;短期 Access Token 只进入 WebView、GUI 与 Runner 内存。独立 standalone game-chat release/高级自定义模式保留隔离 AppData Developer API Key 与 `/api/external/v1/*`。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。 2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 保留原资产与原文件、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘;其他类型同样只追加派生文件/asset 或子版本,不覆盖、删除或重排源记录。 @@ -548,15 +548,15 @@ type ProjectAgentMudPointAttribution = { 6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。 7. 现有资源编辑、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 中与当前非破坏性编辑切片对应的验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。 8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。 -9. Tauri 远端媒体编辑只使用发布 AppData 私有 External v1 配置;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。 -10. 素材画布生成账本的服务身份固定为 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,不绑定 Developer API Key;确认面板只展示去除路径与凭据的服务 origin。无法用当前 Key 验证的升级前 Key-bound 账本在任何网络动作前显示当前服务 origin 并要求用户确认;确认挑战过期、账本变化或 base URL 变化均失败关闭。确认后 `accepted/running` 只恢复原 GET,`prepared` 只可精确重放冻结的原 POST。全类型资源编辑复用相同显式确认迁移:旧 Key-bound 指纹无法验证时确认前保持零网络动作,确认后已存在 `operationId` 的任务只 GET 原 operation,尚未受理的冻结请求才可复用原幂等键精确 POST。 +9. 普通 Tauri 远端媒体编辑只使用当前陶泥儿登录态与固定官方 origin;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。External v1 配置仅属于独立 standalone game-chat release/高级自定义模式。 +10. 普通素材画布生成账本的服务身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,不绑定 Access Token;高级 External v1 账本只绑定显式服务 origin,不绑定 Developer API Key。两种模式的 `accepted/running` 都只恢复原 GET,`prepared` 只可精确重放冻结的原 POST、原正文和原幂等键。普通模式退出或换号后提升账号 generation,中止并脱离旧请求;旧账号账本在新账号下零网络、零安装,只有重新登录同一 owner 后才可恢复。不能通过更换 Token、Key、URL、请求正文或 operationId 绕过该隔离。 11. 资源编辑恢复面板必须为独立 modal,展示后端权威队列的全部 operation。用户可继续任意可恢复项;`remote-failed` 只允许显式移出活动队列,并保留私有账本审计;`reconciliation-required` 只读展示对账。读取失败必须提供重试,不得伪装空队列;操作后必须重读后端。 12. `remote-failed` 已是远端明确终态,重启后不再 POST、不再轮询、不再扣费;`archived` 仅表示用户已将它移出活动恢复队列,不等于 `committed`。`result-unknown`、鉴权临时失败和 `reconciliation-required` 均不允许归档或重新生成。 13. 派生资产提交必须通过 durable asset transaction journal 串起最终文件、manifest 和 project revision。任一崩溃阶段恢复后只有一份派生文件、一条 manifest asset,revision 精确推进一次;manifest 已写而 revision 未写时只前向补 revision,无法证明的组合进入人工对账。journal 已证明目标 asset、媒体和 target revision 写入后,即使后续合法提交继续推进 manifest/revision,也应按目标 asset 精确身份与 `currentRevision >= targetRevision` 补齐 ledger/draft,不得要求整个 manifest 永远等于历史 after 快照。durable committed 后遗留的 staging 只有在 staging/正式媒体摘要一致,且 manifest 中按 asset ID 或路径唯一命中并与 journal asset 精确相等时才尽力删除;删除失败不降级已提交结果,身份或媒体漂移则保留 staging 并进入对账。 14. generation progress、草稿保存队列、生成/提交回包与延迟 `loadDraft` 必须共用单调 revision 门禁,低 revision 不得覆盖已落地的新草稿。Shift 指针与键盘选择必须与共享 core 一致;单选自身 Shift 不能清空选择,已选多图层普通指针拖动应保持并同步移动选择集。零位移不得产生 undo、documentVersion 或草稿保存。 15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。只有生成失败可以保留参数并“返回修改/重新确认”;草稿、CAS、提交、恢复和取消故障不得出现会发起新生成的按钮。任何非 `editing` 生命周期都必须同时禁用或 inert 背景画布/顶部工具栏,并由生成 handler 再次校验当前状态;modal 遮罩、视觉 disabled 或旧闭包都不能充当业务门禁。 16. 派生子版本 journal 必须冻结 project revision before/after 身份和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份且当前 revision 已推进、无法证明是同一事务写入时进入人工对账,不得把现状猜测为已提交。 -17. 文本、SVG 与 Agent 回执编辑必须在调用 Provider 前持久化 request-issued;Provider 成功正文必须在解析、格式校验和 staging 之前原子写入私有 durable handoff,并绑定原 operation、请求指纹和内容摘要。issued 后缺少可信 handoff 只能对账,已有可信 handoff 则只消费原正文,两者都不得再次调用 Provider。401/403 只表示当前凭据不能继续授权,不能把已受理 operation 改写为永久对账;修正 Key 后仍只查询原 operation。 +17. 文本、SVG 与 Agent 回执编辑必须在调用 Provider 前持久化 request-issued;Provider 成功正文必须在解析、格式校验和 staging 之前原子写入私有 durable handoff,并绑定原 operation、请求指纹和内容摘要。issued 后缺少可信 handoff 只能对账,已有可信 handoff 则只消费原正文,两者都不得再次调用 Provider。普通模式首次 `401` 只允许当前账号 generation 的单飞 refresh,成功后以更高 generation CAS 安装 Token,并使用原正文、原幂等键重试一次;refresh 失败、换号或再次 `401` 时保留原 operation,停止网络。`403` 不触发 refresh。高级 External v1 模式修正 Developer Key 后仍只查询或恢复原 operation。 ### 7.7 主站 UI 对齐验收 diff --git a/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md new file mode 100644 index 000000000..45b940c19 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md @@ -0,0 +1,261 @@ +# AGC 无人值守游戏生成可靠性收口实施计划 + +日期:`2026-08-13` + +## 1. 目标 + +本次收口的验收对象不是单个报错点,而是一次完整的用户任务: + +> 用户提交一段游戏需求后,不再确认权限、不再补充“继续”、不再手工重试,AGC 在有界时间内自动产出一版可玩的游戏,并完成当前 revision 的静态验证与桌面、移动双视口真实试玩;若外部依赖或环境确实不可恢复,则必须自动收束为带安全原因的明确失败,不能永久停留在运行中、待确认或等待某个专业 Agent。 + +“产出了一份 HTML”不等于完成。最终完成必须同时满足: + +1. 权威入口产物存在、非占位且可解析。 +2. 游戏具备真实主要玩法,而不是装饰按钮或固定奖励假体。 +3. `playable-web-game-state.v1`、start、primary-action、restart 合同可由真实浏览器执行。 +4. 当前 revision 通过 `game.static_smoke`。 +5. 当前 revision 通过 desktop 与 mobile `preview.validate`。 +6. Runtime、manifest、产物和验证回执的终态一致。 + +## 2. 当前失败基线 + +本次复现暴露的不是孤立实现缺陷,而是生成控制面的系统性断链: + +- 普通工作台提交使用 `project-supervisor-gui`,进入固定专业任务图;一个单 HTML MVP 也会被美术、音频等前置依赖阻塞。 +- 现役 `project-supervisor-game-chat` 已具备“单主 `code-prototype` + 按真实缺口动态委派美术”的快车道,但普通 GUI 默认没有复用它。 +- 工作台仍展示“严格审批”,运行期间可以进入 `waiting-for-confirmation` 或要求用户继续,不满足无人值守目标。 +- `game.static_smoke` 失败时,持久回执可能只剩 `safeDetail=null`、`detailUnavailable=true`;同一 owner 看不到失败项,只能猜测修补。 +- 可修复的验证失败可能结束旧任务、留下空队列或失败卡片,未保证回到同一主 Run 继续“诊断 -> 修复 -> 重验”。 +- 子任务可以先报告 completed,再在投影阶段发现正式产物缺失,造成 Runtime 终态、manifest 状态和文件事实不一致。 +- `execution-owner` 对应进程消失后,持久 Runtime 仍可能显示 running,缺少自动对账和续跑闭环。 +- game-chat 当前首个可玩版本软预算为 4200 秒、硬上限为 4500 秒;固定图和重复猜错会把简单任务拖到一小时以上。 + +直接 Codex 能在数分钟内生成明显更完整的可运行雏形,说明首要瓶颈是 Runtime 的路由、反馈和验收控制,而不是基础模型完全不具备实现能力。 + +## 3. 范围 + +### 3.1 本次必须完成 + +1. **默认单主生成路由** + - 普通 AGC 项目工作台的新建/修改游戏请求默认使用 `project-supervisor-game-chat`。 + - 持久路由前零 child;持久路由后只启动 `code-prototype`。 + - 美术只在 `asset.list` 证明精确缺口后动态委派,且一次只处理一个依赖槽。 + - 保留 `project-supervisor-gui` 给显式专业 DAG/开发诊断入口,不再作为普通生成默认值。 + +2. **无人值守安全策略** + - 对可信 game-chat autonomous root 及其绑定 child,项目内可恢复写入、受限静态验证、真实试玩、任务路由和严格边界内的动态委派自动执行。 + - 该模式不得进入普通 `waiting-for-confirmation` 或 `waiting-for-user-input`;模型信息不足时先采用目标合同允许的安全默认值。 + - 越界路径、任意命令、发布、凭据、系统设置和未列入白名单的外部副作用继续失败关闭,不能为了“无人值守”扩大权限。 + +3. **可操作的验证诊断与自动修复循环** + - `game.static_smoke` 失败回执向同一 run owner 返回脱敏、结构化的失败码、检查项、项目相对路径和有界说明。 + - 公共聊天和跨 owner 回执只展示安全摘要,不泄露绝对路径、命令原始输出、密钥、URL query 或内部 fingerprint。 + - 可修复失败必须回到同一 `code-prototype` Run,继续修改后按“脚本解析 -> static smoke -> desktop/mobile preview.validate”顺序重验。 + - 相同 revision、相同失败指纹无新 mutation 时不得无限重试;命中停滞门后明确失败。 + +4. **完成投影与正式产物一致性** + - owner 子任务进入 completed 投影前,逐项验证其声明的正式 artifact 存在、非空,并对 JSON/PNG/HTML 执行已有格式门。 + - 只读验证任务不得声明由自己写入的正式文件产物。 + - 产物缺失时不能把 manifest 写成 completed;必须产生可修复 blocker 并由主 Run 接管,或在不可恢复时明确 failed。 + - 根 Run 只有在当前任务图、正式产物、static smoke 和双视口试玩全部对齐后完成。 + +5. **Runner 消失后的自动对账** + - 读取 `execution-owner` 时同时核对 boot identity 与进程存活性,不能只相信 JSON 中的 PID。 + - owner 已消失且 durable task 仍为可恢复活跃态时,新 Runner 启动/项目 hydration 自动恢复队列;不能永久显示 running。 + - 外部副作用结果未知时进入 `needs-reconciliation` 并保留证据,不能盲目重做;纯 Provider、项目内文件和验证动作可以按现有 durable checkpoint 安全续跑。 + - 对账与恢复必须幂等,同一 run 不产生重复 child、重复消息或重复付费生成。 + +6. **有界端到端验收** + - 新增一个从普通 GUI 提交到 game-chat 单主路由的回归入口。 + - 用确定性 Provider/工具夹具覆盖“首次 smoke 失败 -> 同主 Run 取得具体诊断 -> 修复 -> smoke 通过 -> 双视口试玩通过 -> completed”。 + - 覆盖 owner 中途消失后新 boot 自动续跑,最终只产生一个根终态。 + - 任何人工确认、人工澄清、固定专业 DAG 等待、旧 revision 验证复用或 console error 都使 E2E 失败。 + +### 3.2 本次不做 + +- 不直接修改用户下载目录中的失败示例;它只作为复现样本。 +- 不把直接 Codex 生成的某个三消 HTML固化成平台模板。 +- 不恢复已退役的主站玩法入口、公开作品系统或旧创作模板后端。 +- 不允许任意 shell、项目外路径写入、自动发布或自动消费未知外部付费能力。 +- 不以隐藏错误、自动点击确认或延长预算冒充可靠性修复。 + +## 4. 目标状态机 + +```text +accepted + -> route-pending + -> code-prototype-running + -> asset-audit + -> optional-art-delivery + -> implementation + -> syntax/static-validation + -> desktop-mobile-playtest + -> completed + +可修复失败: +validation-failed -> owner-repairing -> validation + +进程中断: +owner-lost -> recovery-scan -> same-run-resumed + +外部结果未知: +owner-lost -> needs-reconciliation -> explicit terminal/recovered + +不可恢复或停滞: +any active state -> failed (safe terminal reason) +``` + +普通无人值守生成路径禁止出现: + +```text +waiting-for-confirmation +waiting-for-user-input +fixed-art/audio dependency wait +running with a dead owner and no recovery record +completed with missing artifacts or stale validation +``` + +## 5. 实施切片 + +### 切片 A:入口和路由收敛 + +主要位置: + +- `apps/ai-game-creator-shell/src/App.tsx` +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs` + +实施内容: + +- 把普通项目工作台的 autonomous build source 改为 game-chat 单主来源。 +- 把 source 选择抽成可测试的纯函数,避免 UI 分支再次漂移。 +- 保持显式专业/CLI 调试来源不变。 +- 调整工作台状态投影,不再向默认用户展示固定专业 DAG 和无效“严格审批”承诺。 + +### 切片 B:无人值守权限与失败反馈 + +主要位置: + +- `runtime_tools/policy.rs` +- `runtime_actions/provider_action_batch.rs` +- `runtime_tools/command_ops.rs` +- `runtime_actions/action_audit.rs` +- `runtime_actions/provider_request_builders.rs` + +实施内容: + +- 为可信 game-chat autonomous binding 建立窄白名单自动策略。 +- 对该来源的确认型动作做“安全自动执行或明确拒绝”二分,不能挂起等待。 +- 为 `command.run_limited/game.static_smoke` 定义稳定的 safe detail schema。 +- 保证同 owner 的下一轮 Provider context 能读取失败项,公共 UI 仍只拿安全摘要。 +- 增加失败指纹与 revision liveness 测试,阻止无 mutation 重复 smoke。 + +### 切片 C:完成门和恢复对账 + +主要位置: + +- `runtime_protocol/autonomous_completion.rs` +- `runtime_driver/task_start.rs` +- `runtime_driver/recovery_scan.rs` +- `runner/project_owner.rs` +- `runner/recovery.rs`(以实际调用边界为准) + +实施内容: + +- 将 artifact 合同校验前移到 terminal projection。 +- 对完成声明、manifest 状态和文件事实做一次原子/锁内判断。 +- owner 丢失时按 durable action 类型判断 same-run resume 或 reconciliation。 +- 恢复后重新核对当前 revision,旧 smoke/试玩回执不能过门。 +- 缩短无进展窗口;预算只限制时间,不能替代完成门。 + +### 切片 D:端到端门禁和权威文档 + +主要位置: + +- `apps/ai-game-creator-shell/tests/appSurface.test.ts` +- Runtime Rust 定向测试模块 +- `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` +- `docs/project-memory/shared-memory/development-workflow.md` +- `docs/project-memory/shared-memory/decision-log.md` +- 必要时 `docs/project-memory/shared-memory/pitfalls.md` + +实施内容: + +- 固定普通 GUI -> game-chat 的来源契约。 +- 增加无确认、失败自修复、artifact 真实性和 owner-loss 恢复测试。 +- 增加确定性 E2E;真实 Provider smoke 作为现场验收,不把 mock E2E 说成真实生成已经成功。 +- 把新的默认路由、无人值守安全边界和复验命令写回权威文档。 + +## 6. 验收矩阵 + +| 场景 | 预期结果 | +|---|---| +| 普通工作台提交单 HTML 游戏需求 | source 为 `project-supervisor-game-chat`,只启动 `code-prototype` | +| 素材完整 | 零美术委派、零额外扣费 | +| 缺少规范图和图集 | 先规范图后图集,一次一个 delivery,主 Run 认领后继续 | +| 项目内文件修改、static smoke、preview validate | 在可信 autonomous binding 下不等待人工确认 | +| 请求任意系统命令或项目外写入 | 明确 blocked/failed,不执行,也不挂起等待确认 | +| 首次 JS/static smoke 失败 | 同一 owner 得到结构化失败项,修改后自动重验 | +| 连续同 revision 同错误 | 停滞门阻断重复猜测,明确失败或回到可行动步骤 | +| 子任务回复 completed 但 artifact 缺失 | manifest 不得 completed;产生修复 blocker | +| 最后 mutation 后只存在旧验证 | 根 Run 不得 completed,强制重验当前 revision | +| Runner 在 Provider/文件动作间退出 | 新 boot 幂等恢复同 run,不重复 child/消息 | +| Runner 在未知付费外部动作中退出 | `needs-reconciliation`,不自动重复扣费 | +| 最终完成 | 无 console error;static + desktop/mobile playtest 均属于当前 revision | + +## 7. 验证命令 + +按实际改动范围至少运行: + +```bash +npm --prefix apps/ai-game-creator-shell run typecheck +npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts --run +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_chat_ -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml action_receipt -- --nocapture --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_execution_owner -- --nocapture --test-threads=1 +npm run check:encoding +git diff --check +``` + +如果完整 Rust suite 受既有 Windows 文件锁影响,必须单独复跑新增 filter,并在交付中如实记录完整门仍不干净;不能用定向通过替代全量结论。 + +现场真实验收还需要: + +1. 使用新的空项目提交一个明确、无需追问的小游戏需求。 +2. 全程不点击确认、不发送继续、不手工重试。 +3. 记录首个可玩版本耗时、Provider/工具轮数、委派数和失败修复次数。 +4. 核对最终 manifest、current revision、static receipt、desktop/mobile playtest receipt 和浏览器 console。 +5. 中途强制结束 Runner 一次,重新启动客户端后确认 same-run 自动续跑且没有重复付费动作。 + +## 8. 完成定义 + +只有以下条件全部满足,本计划才算实现完成: + +- 普通 GUI 默认进入单主快车道。 +- 一个确定性端到端夹具证明首次验证失败可由同一主 Run 自动修好并最终完成。 +- 一个 owner-loss 夹具证明新 boot 自动恢复且终态唯一。 +- 可信无人值守路径不存在普通人工确认或澄清等待。 +- completed 前 artifact、当前 revision static smoke 与双视口试玩均被强制复核。 +- 相关定向测试、类型检查、编码检查和 diff 检查通过。 +- 使用真实 Provider 的空项目 smoke 成功;若现场外部服务不可用,则代码可以提交为“实现完成、真实现场验收未完成”,不得宣称目标已完全达成。 + +## 9. 实施与验证记录(2026-08-15) + +在 `codex/agc-runtime-generation-reliability` 分支完成: + +- 可信 `code-prototype` 父 Run 可认领并观察直属美术 delivery、按 `delegationId` 精确读取合同;错误 Agent/Run、未知合同与身份篡改统一失败关闭。父 task/chain 无法证明但仍有活跃或已认领 delivery 时 completion 必须 blocked;Suppressed 且未形成 child 的失败前置记录不参与 capability、claim 与 completion barrier。 +- 普通失败或不合法安全默认 marker 认领后立即收束;合法 `game-chat-safe-default-repair.v1` marker 由 Runtime 直接生成唯一一层、同目标、同合同 `agent.delegate`,repairRequired 状态下重复 route/read/query 被 liveness 门拒绝。 +- Windows `.agent/project.lock` 不再对最终 `create_new` 目标做 metadata 预检,delete-pending 的 5/32/33 统一进入有界竞争等待;新增 delete-pending 回归,复现真实 `create_new` ACCESS_DENIED 后证明等待可收束。 +- 验证结果:`game_chat_` 113 通过、`autonomous_completion_contract` 107 通过、`safe_default` 4 通过、`static_smoke` 9 通过、`action_receipt` 10 通过、`project_execution_owner` 8 通过、runner 重启恢复与 Windows 锁回归通过;串行全量 Rust `1856 passed / 0 failed / 15 ignored`(含新增 HTML 内联语法 fail-open 回归);`platform-llm` 与 `agent-runtime-core` 全绿;前端 typecheck、`check:encoding`、`git diff --check`、变更文件 `rustfmt --check` 通过。 +- 真实 `gpt-5.6-sol / reasoningEffort=max` 隔离轮次:14 个 Provider 请求全部完成并闭合,无确认、追问、steer、路径/密钥/正文泄漏,Runner 与后代进程清理为零残留;在未配置 External Editor 的边界下 art-director 失败后由主 Run 认领并快速明确收束,未再次空转。 +- 待完成:用有效 External Editor 配置跑一轮完整 playable 真实验收;当前结果不能宣称现场完整生成已通过。 +- 2026-08-15 后续修复:Runtime 配置保存成功或失败均显示可见 toast;默认 `gpt-5.6-sol / reasoningEffort=max` 已同步到启动配置门禁,`check-config`、AGC typecheck 和运行时设置定向测试(6 项)通过。 + +## 10. 回滚边界 + +- 默认路由可回退到原 `project-supervisor-gui`,但不能同时保留两套普通入口形成随机分流。 +- 自动权限只由可信 source + root profile + binding fingerprint 共同启用;回滚时删除该窄例外,不修改全局策略默认值。 +- 新 safe detail schema 只增加脱敏诊断,不改变原始审计账本的权限边界。 +- 恢复逻辑只消费现有 durable task/action/profile/owner 记录,不删除 `.agent`、锁文件或用户产物。 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 new file mode 100644 index 000000000..d205aa1cc --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -0,0 +1,247 @@ +# AGC 直连 Codex Runtime 迁移实施计划 + +日期:`2026-08-15` + +## 1. 目标 + +新增一条面向普通 AGC 项目的“直连 Codex Runtime”链路:用户在项目聊天中输入需求,客户端把聊天内容、项目工作区和受限系统提示词直接交给 Codex app-server,由 Codex 自己完成理解、文件修改、命令执行、验证和回复。产品默认只使用 `codex_app_server`,不再让用户选择 Provider / CLI / Supervisor 模式。 + +本次迁移保留现有 Supervisor、专业 Agent、harness、real-E2E 和旧 Runtime 源码及测试,旧链路只作为开发诊断、回滚和历史数据读取能力,不再作为普通项目聊天的默认执行入口。 + +## 2. 当前问题证据 + +- `gameagent-20a04b8f` 首轮生成的入口触发 `ReferenceError: tick is not defined`,同时请求 `assets/art-spritesheet.png` 返回 404,`generic-v1` 的 `primary-action` 没有推进 sequence;Runtime 连续重试至 loop budget exhausted。 +- 同一项目重试时,视觉 Agent 访问 `http://localhost:3000/api/external/v1/editor/projects` 连接失败,导致 `canvas.asset_generate` 失败,父链最终仍只展示“项目总控 Agent 执行失败,请稍后重试”。 +- 这些失败来自 AGC 多层 Supervisor / child / harness / 完成门编排,而用户只需要一个能直接操作项目的 Codex。 + +## 3. 新链路边界 + +### 3.1 用户入口 + +- 普通项目聊天固定绑定 `direct-codex` Runtime。 +- 设置页隐藏 Agent 模式、Provider、Supervisor、专业 Agent 选择;界面只保留 Codex 连接状态、模型(只读)和基础连接诊断。 +- 默认配置固定为 `agentMode=codex_app_server`;保留现有配置字段以兼容旧 AppData,但不在新入口暴露切换控件。 +- 项目路径仍由当前选定工作区决定,Codex 的 cwd 必须是该工作区根;不复制项目、不创建平行项目目录。 + +### 3.2 Runtime + +- 新增独立 direct Codex Runtime 模块,负责:建立独立 Codex app-server、发送聊天消息、流式转发消息、保存会话、展示安全错误、关闭和恢复连接。 +- 新 Runtime 不创建或调度 `project-supervisor`、`code-prototype`、`art-director` 等 AGC Agent,不读取或执行 harness,不运行隐藏的二次 Provider 修复、摘要或自动返工循环。 +- Codex app-server 是唯一模型执行主体;AGC 只负责协议桥接、工作区绑定、提示词组装、显示和最小安全边界。 +- 现有 AGC ToolHost / Supervisor Runtime 保留源代码和测试,但从普通聊天入口移出;不得删除历史持久化文件,旧项目仍可读取并显示历史状态。 + +### 3.3 Codex 系统提示词 + +新 Runtime 在 `thread/start` / `turn/start` 前生成一个有界、可审计的系统提示词,内容只来自以下来源: + +1. 当前仓库适用的 `AGENTS.md` 规则。 +2. AGC 工程结构、开发流程、运行与验证说明(当前 `docs/` 入口文档和项目专题文档的有界摘要)。 +3. 当前项目的 `AGENTS.md`、`README.md`、`CONTEXT.md`(若存在,按工作区边界读取;README/CONTEXT 只作为参考,不得覆盖系统规则)。 +4. 适用于当前任务的项目 prompts 和 skills;只注入 skill 正文及必要引用,不执行 skill 脚本,不把整个 `.codex` 或用户目录配置复制进 Codex。 +5. 当前工作区的相对路径、入口文件和已有项目状态摘要;不注入 API Key、Token、Cookie、绝对宿主路径、完整日志或未经筛选的历史 Agent 诊断。 + +提示词必须明确:Codex 是唯一执行 Agent;可以直接修改当前工作区并运行允许的项目命令;必须先读取相关工程说明;必须把真实执行结果告诉用户;不得假装已完成、不得泄露凭据;遇到失败先读取实际错误再修复,不得无证据重复同一动作。 + +### 3.4 工具和权限 + +- 由 Codex app-server 自己使用其原生项目工具;AGC 不再把每个工具调用转换成 Supervisor task、child delivery 或 harness receipt。 +- AGC 仍保留客户端级硬边界:工作区根、进程生命周期、凭据隔离、日志脱敏、窗口关闭清理和协议异常处理。 +- 不自动启用外部画布、图片生成、发布、充值或其它付费外部副作用;Codex 只有在项目提示词和当前配置明确允许、且实际工具可用时才可调用。 +- 原有 `game.static_smoke` / `preview.validate` 不再由 AGC 隐藏地强制编排;如果用户要求验收,提示词要求 Codex 自己运行并报告结果。 + +## 4. 实施步骤 + +1. **入口与契约**:定义 `direct-codex` Runtime 状态、消息和错误 DTO;普通项目聊天默认切换到新入口;旧 Supervisor 入口保留为开发兼容入口。 +2. **Codex 桥接**:复用现有 app-server 进程隔离、临时 `CODEX_HOME`、认证桥接和进程清理能力,新增只发送用户消息 + 系统提示词的 direct turn API;拒绝 server→client 未声明请求和非消息原生 item。 +3. **提示词构建器**:按工作区边界读取 AGENTS / docs / prompts / skills,做大小、敏感信息和路径脱敏门禁,记录提示词来源 fingerprint,不记录正文。 +4. **前端收敛**:隐藏模式选择和 Supervisor/专业 Agent 展示,聊天区直接显示 Codex 流式消息、执行状态和可读失败原因;保留设置保存 Toast。 +5. **恢复与持久化**:direct session/run 使用独立 source 和稳定 messageId;断线恢复只恢复同一 Codex thread,不重新执行未知副作用;旧 Supervisor 任务不自动迁移成 direct turn。 +6. **失败可见性**:把 Codex 的安全错误分类为连接、鉴权、上下文、工具执行、命令验证和进程退出,并在 UI 展示具体可操作原因;禁止继续只显示“请稍后重试”。 +7. **文档与回滚**:同步技术方案、开发说明和运行配置说明,保留一个开发开关让维护人员读取旧 Runtime,但产品默认不可见。 + +## 5. 验收 + +- `npm --prefix apps/ai-game-creator-shell run typecheck` 通过。 +- 新 Runtime 单测覆盖:系统提示词来源/敏感信息过滤、工作区 cwd、Codex turn 透传、流式消息幂等、错误映射、断线恢复和进程清理。 +- fake app-server 协议测试证明:一次用户输入只产生一个 direct turn;无 Supervisor/child/harness 调度;API Key 不进入 argv、日志或提示词正文;server 请求被拒绝;native tool item 按协议处理。 +- AppSurface 测试证明:模式选择不可见、默认 Codex app-server、普通聊天消息直达 Codex、失败原因可见、旧项目历史仍可读。 +- `npm run check:encoding` 与 `git diff --check` 通过。 +- 显式 opt-in 的真实 Codex app-server smoke:在隔离项目中完成一次聊天、一次真实文件修改和一次用户可见回复;单独报告 Provider / Codex 网络与本机认证结果,不用旧 harness self-test 冒充。 + +## 6. 不做事项和回滚 + +- 不删除 Supervisor、专业 Agent、harness、真实 E2E 或旧 Runtime 源码;不批量迁移旧 `.agent` 状态。 +- 不把 Codex 原生工具能力无条件扩大为 AGC 的全局权限;安全边界仍由进程、工作区和配置隔离保证。 +- 新 Runtime 通过独立 source / feature flag 回滚到旧入口;回滚不重写用户项目文件、不删除 `.agent`、不重放未知外部动作。 + +## 7. 当前落地状态(2026-08-15) + +- 已新增 `direct_runtime` Tauri 链路:普通聊天直接调用 Codex app-server,cwd 绑定当前项目,使用 workspace-write 沙箱;旧 Supervisor/harness 源码未删除。 +- 已新增有界系统提示词构建器,注入项目规则、工程说明和执行约束,并屏蔽凭据与宿主私密信息。 +- 设置页已隐藏模式选择,保存配置时固定 `agentMode=codex_app_server`。 +- 已通过 `cargo check`、direct Runtime 单测、AGC typecheck、配置契约检查和 `git diff --check`。 +- 修复了 direct app-server 在隔离 `CODEX_HOME` 下的无人值守写入阻断:direct workspace 使用 `approvalPolicy=on-request`,AGC 对受限 file-change 请求自动接受;隔离 Codex 配置仅将当前工作区登记为 trusted,旧 Runtime 仍保持 `approvalPolicy=never`。 +- direct workspace 同时禁用 `shell_tool` / `unified_exec`,避免模型在无人审批策略下退回到被系统拒绝的 PowerShell/Python 命令;原生 file-change 仍由 Codex app-server 执行。 +- 新增仅在 `GENARRATIVE_AGC_DIRECT_DEBUG` 显式开启时输出的脱敏 app-server 事件/stderr 诊断;默认不输出正文或凭据,且覆盖 Bearer/API key/token 脱敏测试。 +- 已用当前 AGC Rust 二进制真实验收:在 `D:\Documents\Genarrative GameAgent\client-direct-codex-20260815` 先创建并删除测试 marker,再由 direct turn 实际写入 `game/index.html`、`game/style.css`、`game/game.js`;`node --check game/game.js` 通过。 +- 已用本地静态 HTTP 服务 + Playwright 打开生成结果:页面标题、Canvas、订单/金币/等级 UI 正常,点击棋盘得到真实“不消除”反馈,点击“整理货架”后金币从 `40` 变为 `20`。仅 favicon 404,不影响游戏入口。 +- 首页“自动创建项目”现已把首条完整需求交给 `chat_with_game_creator_direct_codex`,不再创建 Supervisor Session、不再启动 `start_game_creator_supervisor_runtime_task`;进入 direct 项目时也不再自动调用 `resume_game_creator_agent_runtime_tasks`,避免新项目显示无来源的 Resume。 +- AppSurface 定向测试锁定“自动建项目只发一次 direct Codex、显示 direct 回复、Supervisor start=0、Runtime resume=0”,并已实际通过。 +- 项目开发页中的 direct 组件现在会先 hydrate 外层已选工作区;已有项目不会在首条消息时误创建第二个自动目录。自动建项目或 direct Codex 连接失败后立即停在可见错误,不再落回旧 Supervisor Runtime。 +- 系统提示词会按工作区边界加载有界、脱敏的 `prompts` 与 `SKILL.md` 摘要,并附带内置 direct-game-creation skill;不会把 API Key、Cookie、auth.json、`.env` 或宿主绝对路径带入 Codex 上下文。Direct turn 成功后会刷新 AGC manifest 并尝试启动客户端本地预览,预览失败会把具体原因写入聊天和状态栏。 +- 系统提示词现作为 Codex `thread/start.baseInstructions` 发送,用户消息单独作为 `turn/start.input`,不再把 AGC 规则伪装成用户文本;仓库级 `AGENTS.md` 与 AGC 技术方案以编译期有界摘要随客户端提供,脱离源码 checkout 的项目也能获得工程约束。 +- direct Codex thread 按项目复用同一个进程内 ephemeral session,连续消息保留 Codex 上下文;客户端退出时统一关闭 direct app-server,连接中断后下一次消息会淘汰关闭节点并重新建立连接。 +- Codex app-server 的 `usage-limit`、鉴权、上下文超限、沙箱失败、终态未知等稳定错误现在映射为可行动的中文提示;工作台 direct 页面隐藏 Supervisor/专业 Agent 状态卡,保留单一 Codex 状态和真实错误。 +- 运行页已直接移除“测试切片”控制行及其本地播放/序号状态和样式,只保留游戏运行画面、信息展示与数值微调;AppSurface 合同同时锁定该控件不再渲染、对应 CSS 不再存在。 +- 当前 direct 产物是多文件入口,而历史 `validate_game_html_smoke` 仍按单文件内联 HTML 契约检查;本次 direct 链路不隐藏触发该旧门禁,若要把多文件产物纳入旧门禁需另行设计读取工作区文件的验证契约。 + +## 8. 陶泥儿美术闭环与资源分类收口(2026-08-16) + +### 问题 + +- 直连入口此前仅根据用户输入中的少量“游戏 + 创建”关键词决定是否调用陶泥儿生图;“做个三消模拟经营”等正常创作输入会绕过该判断,导致只有 HTML / CSS / JS 的项目仍被投影为已完成。 +- Codex app-server 没有陶泥儿平台的生图工具;单靠泛化系统提示词无法让它自行调用平台生成、下载、登记并回写美术资源。 +- 资源画布把 `text/html`、`text/css` 和 `text/javascript` 统一归为“文档”,混淆了玩法说明与可执行游戏代码。 + +### 现行契约 + +1. 直连 AGC 项目首次生成、或当前项目还没有已登记陶泥儿美术时,客户端按标准三阶段优先调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。`sliceLayout=grid-2x2` 与四份受合同保护切片继续作为标准美术包的推荐路径;不得把客户端猜测裁剪或自由连通域结果冒充平台切片。平台返回的主图片必须以 `source.kind=canvas` 登记,资源身份、图片可解码性和同 Canvas 项目关系继续失败关闭;图集源图可信可用但透明化或切片后处理缺失时,按第 13 节安全复用源图并继续同一 Codex thread,不因固定切片缺失终止整个游戏。 +2. 客户端把实际可用的规范图、背景图、主图集与切片相对路径、来源和用途作为不可变执行上下文提供给唯一 Codex。规范图作为风格根,其余平台素材应进入核心可玩画面;Codex 可以根据游戏结构选择合理的图集、切片、Canvas 或 WebGL 组织方式,不要求固定文件组合、固定 `drawImage` 次数或单一代码形态。平台素材不能只在回复中声明使用或只显示为旁侧缩略图,核心视觉也不能退化为 emoji、纯 CSS 或纯色占位图。 +3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。 +4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。 +5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。 +6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。 +7. direct app-server 禁用 shell / unified exec 时仍必须能可靠修改已有游戏。每轮系统提示词在大段仓库文档之前注入当前 `game/index.html`、`game/style.css`、`game/game.js` 的有界脱敏快照,使 Codex 的原生 file-change 能基于真实旧内容生成补丁;不得退化为猜测变量名的盲补丁。回合前后以三个代码文件的内容指纹判断是否发生真实修改;无修改且 manifest 已同步的回合不得推进 revision 或伪造新项目版本。 + +### 验收 + +- Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。 +- 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。 +- 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。 +- 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。 +- 真实客户端验收必须在新建空项目中看到实际成功生成或安全恢复的 `source.kind=canvas` PNG 位于“美术资源”,代码文件位于“游戏代码”,并在 desktop/mobile 运行画面的核心 Canvas/WebGL 路径中观察到至少一份已登记陶泥儿素材;标准四切片存在时应优先使用,但不存在时不能伪造,也不能据此单独判定游戏失败。 + +## 9. 直连创作阶段反馈与资源首屏预览(2026-08-17) + +### 问题 + +- 直连回合把陶泥儿美术生成、Codex 文件修改、manifest/版本登记放在一个 Tauri command 内。前端只能在 command resolve 后得到最终回复,用户会在数分钟内只看到笼统等待状态,随后突然出现运行画面。 +- 资源卡原本主要等待嵌套资源画布的 `IntersectionObserver` 通知。该嵌套滚动容器在首次进入时不稳定,失败后的可重试读取又不会因可见性回调自动重试,导致用户必须先进入详情才能看到卡片预览。 + +### 现行契约 + +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 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。若项目已有同一画布、身份可信且可解码的规范图和背景图,但历史核心图集无法恢复,客户端必须直接复用这两张素材继续 Codex 代码生成与试玩,不重新发起图集付费生成,也不为只读恢复失败阻断本轮。只有已用平台图片本身无法下载、解码、无可见像素、来源身份不可信或最终没有任何真实平台素材引用时才阻断。 + +### 13.2 同一 Codex thread 的有界自主验收 + +每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness: + +1. 首轮由 Codex 生成或修改当前工作区文件。 +2. 客户端先复核最低完成证明:`game/index.html` 是否存在,以及源码是否引用至少一个已登记的平台图片。该证明不满足时,不在版本登记阶段直接报错;客户端把脱敏的具体缺口回灌给同一 Codex thread,待其修复后才启动受限本地预览。该回灌不允许 Codex 修改 manifest、伪造素材或覆盖来源身份。 +3. 最低证明满足后,客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 +4. 客户端把脱敏、结构化的浏览器证据及仍存在的最低完成证明缺口发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 + +最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。 + +### 13.3 最低使用证明与完成条件 + +客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。该最低证明失败必须先进入同 thread 有界整改,而不是在浏览器成功后才由版本登记阶段突然终止;若最终仍失败,才拒绝登记。 + +受限 Chromium 还会对已登记平台图片采集运行时使用观察:图片是否在真实 Canvas / WebGL 渲染调用中出现,以及是否只作为页面可见图片出现。该观察不依赖固定切片、固定文件名、特定棋盘结构或字符串计数,也不替代 Codex 对截图和玩法质量的判断;但若只观察到旁侧预览、未观察到核心渲染路径,客户端必须把这一明确缺口回灌给同一 Codex thread 要求整改,不能把“源码引用”或“旁侧缩略图”表述为素材已进入玩法。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 + +### 13.4 验收要求 + +- 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。 +- fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。 +- 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。 + +### 13.5 透明后处理失败时的图集源图安全复用(2026-08-18) + +当陶泥儿 `art-spritesheet` 的远端生成已完成、源 PNG 已落在同一画布,但透明化或切片后处理失败时,直连 Runtime 可以在不重新提交、不重复扣费的前提下只读恢复该源图。恢复仅适用于规范图唯一绑定的核心图集:候选必须属于当前 Canvas、生成路由和种类匹配,且要么 `sourceResourceId` 精确指向当前规范图,要么只包含当前规范图的一项不可变参考。多个候选、外部参考、多参考、不可下载、非 PNG、透明或无可见像素一律失败关闭。 + +恢复成功后将完整图集作为可信平台素材继续交给同一 Codex thread;客户端不得伪造切片、不得把未生成的切片登记为存在。源图已确认由平台完成且只读恢复成功后,客户端清理同一 `agentId/runId` 的已完成生成账本;只读恢复失败、身份不唯一或结果仍未知时继续保留账本供对账,绝不删除后重发。若没有可安全恢复的历史图集、但规范图和背景图已经可信可用,直连生成可继续使用这两张素材并完成上述 LLM 自主验收,不再为只读恢复失败重发图集请求。 + +### 13.6 隔离验收进程的陶泥儿开发者凭据(2026-08-17) + +真实 CLI / Chromium 验收进程不能依赖已打开 GUI 的内存登录态,也不能复制 GUI 的 Cookie、access token 或 Runner 会话。直连 Runtime 首先只读取当前用户私有的 `~/.config/genarrative/external-editor-api.json`:其中保存一次性显示过的开发者 API Key,和可选的受信任陶泥儿 API origin;仓库、项目目录、`game-creator.config.json`、诊断、命令行参数和 Codex 系统提示词均不得出现该 Key。 + +当私有 Key 缺失但当前 GUI 已有陶泥儿账号会话时,客户端可按用户授权仅调用受保护的 `/api/profile/api-keys` 创建一个名称固定的本机直连 Key,并以原子写入和当前用户私有权限保存到上述文件。创建后,direct Runtime 的美术准备、只读图集恢复、素材换签、生成提交和轮询统一使用 External v1 路由及该 Key;不得混用账号 JWT 路由。若 Key 缺失且没有 GUI 会话,必须给出“先在客户端登录一次以创建本机开发者 Key”的可行动错误,而不是误报普通试玩失败。 + +开发者 Key 创建是唯一允许借用 GUI 登录态的受控设置动作;平台图像生成继续由既有幂等账本、同一 idempotency key 和未知结果恢复保护。真实验收必须至少用一个不带 GUI 内存登录态的新进程恢复同一项目,以证明该私钥链路可独立生成、下载并完成 Chromium desktop/mobile 试玩。 + +### 13.7 首次私钥引导的本机安全存储前置与可行动失败说明(2026-08-17) + +首次真实客户端验收表明,失败不是 GUI 登录态未同步:首次本机开发者凭据创建已经拿到远端响应,但 Windows 新建的私有目录 owner 是 `Administrators`,随后 TokenUser 私有 DACL 校验拒绝落盘。一次性显示的远端凭据无法恢复,旧顺序会留下孤儿凭据,因此不能用刷新登录态或自动重放掩盖。 + +1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 和私有 DACL 为当前 TokenUser;已有目录一律按严格 owner/DACL 校验,owner 不匹配时失败关闭,不自动接管、改 ACL、覆盖或删除。 +2. 若本机目录预检失败,客户端返回稳定的“本机开发者凭据存储目录未安全初始化;未创建远端凭据”分类,不请求远端创建接口、不发起美术生成、不写 operation 或账本,也不刷新登录态或自动重试。 +3. 远端响应后原子写入仍可能因并发或磁盘故障失败;该极窄路径必须单独分类为“凭据已创建但未能安全保存”,提示用户在账户开发者凭据页面撤销后再试,不能自动创建第二把凭据。诊断和正式 UI 只展示上述安全摘要与恢复建议,不包含 access token、开发者凭据、响应正文、绝对路径或签名 URL。 +4. 验收先在当前失败项目上恢复:对遗留的空且 owner 不匹配目录做可恢复隔离后,再复用同一项目发送“继续完成此前三消游戏”。成功标准包括本机私钥存在但不读取其内容、平台素材与版本登记完成、desktop/mobile Chromium 试玩证据和隔离进程恢复;此前已创建但无法恢复的远端孤儿凭据作为明确剩余风险,绝不自动撤销。 diff --git a/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md b/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md new file mode 100644 index 000000000..bf26f3614 --- /dev/null +++ b/docs/project-memory/plans/【计划】AGC开发态单窗口启动收口-2026-08-17.md @@ -0,0 +1,20 @@ +# AGC 开发态单窗口启动收口计划 + +日期:`2026-08-17` + +## 目标 + +`npm run agc` 启动时只打开标题为“陶泥儿”的正式客户端窗口,不再自动额外打开 Agent 聊天开发窗口。 + +## 范围与边界 + +- 删除 Tauri setup 中仅 debug 生效的自动 developer 窗口调用,以及已无调用方的 developer 窗口构造代码与专属路由测试。 +- 保留普通 `client` 窗口、`--game-chat` 独立入口和显式 `supervisor-chat` 开发调试入口。 +- 不删除前端 `?agent-chat` 调试页面;它不再是 `npm run agc` 的自动入口。 +- 同步原生壳静态门禁、技术方案和长期决策记录,防止自动双窗口回归。 + +## 验收 + +1. Tauri 定向 Rust 测试和前端类型检查通过。 +2. 配置门禁、编码检查和差异检查通过。 +3. 实际运行 `npm run agc` 后,仅存在标题为“陶泥儿”的客户端窗口,不存在 Agent 聊天开发窗口。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d7b9e7bd5..88ba1980e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,373 +1,27 @@ # 决策记录 -## 2026-08-19 M1 审查后四项可靠性修复 +## 2026-08-15 AGC game-chat 主代码 Run 接管直属美术 delivery -- **审批 stale 收口**:`status` 或 `phase` 为 `needs-reconciliation` 的策划根不再满足审批所需的 active 身份;审批命令返回 `PLAN_STALE_APPROVAL`,并且不得创建 approval receipt。 -- **项目身份先验**:所有手动新建、首页新建、game-chat 新建与 Godot 导入都生成独立 `local-project-`;hydrate 在同一把项目锁内用只读 session 读取 GDD、receipt、pending、session 的 projectId,任一与 manifest 不符即返回 `PLAN_PROJECT_ID_MISMATCH`,不提升 session previous,也不改 index、Markdown 或 audit。 -- **rollout capability**:AppData 配置增加 `planning.capabilityEnabled`,默认 `true`。关闭后拒绝新的 plan 根 run、`plan.submit_gdd`、approval pending 和 decision mutation;hydrate 仅返回已有 sidecar 的只读视图,不执行恢复写入。 -- **profile/source 边界**:durable run-profile binding 集中拒绝 `project-supervisor-plan + autonomous-game-build`;自主构建的 scheduler 与 completion consumer 使用不含 plan 的精确 source matcher,普通 plan `standard` 仍属于通用 trusted matcher。 -- **边界**:不新增既有项目首次进入策划入口;不改做游戏、做素材路径。回归覆盖 capability、stale receipt、错项目 sidecar 写前失败、非法 binding 不落盘与新项目 ID 形状。 +- 背景:真实 `gpt-5.6-sol / max` 验收中,`art-director` 失败后已形成 `ready + needs-repair` delivery,但认领、合同读取、claim observation 和完成 blocker 均硬编码为 Supervisor-only;实际直属父 Run `code-prototype` 无法消费回执,随后又发起 29 次 Provider 请求。 +- 决策:保留原 Supervisor 能力,并把 delivery 管理权限窄扩展给身份链完整的当前可信 game-chat `code-prototype` 父 Run。Runtime 在存在 ready 回执时确定性执行 `agent.run_status(scope=self)`;claim 未完整 Observed 前不请求 Provider;普通失败或不合法 marker 认领后立即终止。完整 `game-chat-safe-default-repair.v1` marker 不再交回 Provider 决策,而是由 Runtime 按原 Agent、原合同和原 delegationId 直接生成唯一一层 `agent.delegate`;repairRequired 状态的重复 route/read/query 由 liveness 门拒绝。 +- 安全边界:权限必须同时证明 game-chat 根 source/profile、root/parent/child task、父 binding fingerprint、delivery parent/session/run、delegationId 和目标 Agent;Agent ID、任务正文或错误关键词不能授权。身份损坏时 capability、run status 和 completion gate 全部失败关闭;父 task/chain 无法证明但仍有活跃或已认领 delivery 时也必须 blocked,不能静默当作“不适用”。Suppressed 且未形成 child 的失败前置记录不再参与 capability、claim 或 completion barrier,以保留同 action reopen / 新 action 重试语义。`code-prototype` 只能管理内部 delivery,正式用户 assistant 与根 completed 投影仍只允许根 `project-supervisor`。 +- 验证方式:覆盖合法认领与合同精确读取、错误 Agent/Run/delegation 拒绝、delivery 身份篡改阻断、唯一安全默认返工、普通失败零后续 Provider lifecycle,以及新的真实 Provider 空项目轮次。顺带收紧 `validate_executable_inline_javascript_syntax`:正文游离 `<` 不再吞掉后续 `