From 427a8c151ab653bb557db4169d88162c96002b81 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Mon, 27 Jul 2026 04:04:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=87=AA=E4=B8=BB=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=AD=A3=E5=BC=8F=E4=BA=A7=E7=89=A9=E4=B8=8E=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增终端 AppData 配置向导并强化跨平台密钥与进程树安全 升级自主构建为十六任务产物 DAG 并接入两类真实画布素材 完善完成基线、并发补验、验证证据与受限画布返工合同 强化确定性与真实 Swarm E2E 的 exactly-once 和正式产物校验 修复确定性 Provider 终态修复后的复验交付状态 同步更新客户端实现计划、决策记录和踩坑说明 --- apps/ai-game-creator-shell/package.json | 4 +- ...ent-runtime-deterministic-playable-e2e.mjs | 793 ++++++- .../agent-runtime-real-e2e/runtime-state.mjs | 2 +- .../suites/supervisor-autonomous-playable.mjs | 8 + .../scripts/agent-swarm-test-chat.mjs | 1355 +++++++++++- .../scripts/check-config.mjs | 611 +++++- .../deterministic-lane-defense-provider.mjs | 1872 ++++++++++++++++- .../scripts/game-creator-config-wizard.mjs | 1042 +++++++++ .../scripts/run-cli-with-config.mjs | 14 +- .../src/agent/generation/canvas_generation.rs | 422 +++- .../src-tauri/src/agent/prompt.rs | 86 +- .../src/agent/runtime_actions/action_audit.rs | 106 +- .../runtime_actions/autonomous_policy.rs | 280 ++- .../agent/runtime_actions/project_gates.rs | 13 +- .../provider_request_builders.rs | 68 +- .../runtime_actions/provider_tool_plan.rs | 62 +- .../runtime_actions/run_status_observation.rs | 43 + .../run_status_observation_tests.rs | 105 + .../runtime_actions/tool_policy_snapshot.rs | 4 +- .../src-tauri/src/agent/runtime_driver.rs | 13 +- .../src/agent/runtime_driver/main_loop.rs | 82 +- .../agent/runtime_driver/main_loop_tests.rs | 234 +++ .../agent/runtime_driver/pending_execution.rs | 2 +- .../agent/runtime_driver/pending_recovery.rs | 2 +- .../agent/runtime_driver/provider_recovery.rs | 223 ++ .../src/agent/runtime_driver/recovery_scan.rs | 33 + .../src/agent/runtime_driver/task_queue.rs | 8 + .../src/agent/runtime_driver/task_start.rs | 674 ++++++ .../src-tauri/src/agent/runtime_protocol.rs | 2 + .../runtime_protocol/autonomous_completion.rs | 608 +++++- .../autonomous_completion_contract_tests.rs | 915 +++++++- .../src/agent/runtime_protocol/models.rs | 1 + .../src-tauri/src/agent/runtime_state.rs | 9 +- .../src-tauri/src/agent/runtime_tools.rs | 4 + .../src/agent/runtime_tools/action_history.rs | 4 +- .../src/agent/runtime_tools/command_ops.rs | 1 + .../src/agent/runtime_tools/delivery.rs | 166 +- .../src/agent/runtime_tools/file_ops.rs | 68 + .../src/agent/runtime_tools/media.rs | 169 +- .../src/agent/runtime_tools/policy.rs | 134 ++ .../src/agent/runtime_tools/project_ops.rs | 8 + .../src/agent/runtime_tools/task_ops.rs | 66 +- .../src-tauri/src/agent_native_tools.rs | 7 +- .../src-tauri/src/assets.rs | 3 +- .../src-tauri/src/project/manifest.rs | 9 +- .../src/tests/collaboration/delegation.rs | 416 +++- .../src/tests/collaboration/recovery.rs | 232 ++ .../tests/collaboration/static_deliveries.rs | 47 +- .../collaboration/supervisor_planning.rs | 4 + .../src-tauri/src/tests/command_runtime.rs | 14 + .../src-tauri/src/tests/project.rs | 58 +- .../src-tauri/src/tests/project_tools.rs | 264 ++- .../src-tauri/src/tests/provider.rs | 3 +- .../src-tauri/src/tests/response_stream.rs | 3 + .../tests/runtime_actions/action_execution.rs | 126 ++ .../planning_strategy/autonomous_build.rs | 428 +++- .../src/tests/runtime_actions/policy.rs | 101 + .../src-tauri/src/tests/runtime_state.rs | 3 + .../src/features/agent-runtime/model.ts | 10 + .../src/features/agent-runtime/panels.tsx | 5 +- .../tests/agentSwarmTestEntry.test.ts | 1255 ++++++++++- .../shared-memory/decision-log.md | 36 + docs/project-memory/shared-memory/pitfalls.md | 44 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 14 + package.json | 2 + 65 files changed, 13090 insertions(+), 310 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index fee5c9adf..c79223d53 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -12,7 +12,9 @@ "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "chat": "node scripts/run-cli-with-config.mjs --swarm-chat", "swarm": "node scripts/run-cli-with-config.mjs --swarm-chat", - "test:chat": "node scripts/agent-swarm-test-chat.mjs", + "config": "node scripts/game-creator-config-wizard.mjs", + "test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open", + "test:chat:manual": "node scripts/agent-swarm-test-chat.mjs", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", 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 89c08ae00..0bf11ca70 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 @@ -9,6 +9,7 @@ import { createDeterministicLaneDefenseRouter, deterministicLaneDefenseInitialHtml, deterministicLaneDefenseModel, + deterministicManifestReadyAgentIds, hiddenCanvasCss, startDeterministicLaneDefenseProvider, visibleCanvasCss, @@ -135,6 +136,11 @@ function responsibilityContractEvidence(stats) { 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, @@ -143,55 +149,139 @@ function responsibilityContractEvidence(stats) { return { ...evidence, codeOwnsGameIndex: - evidence.codePrototypeDelegationCount === 2 && - evidence.codePrototypeExpectedArtifactCount === 2 && - evidence.codePrototypeGameIndexArtifactDelegationCount === 2, + 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: - (qualityPlanningCount === 1 || qualityPlanningCount === 2) && - evidence.qualityRevisionReplanCount === qualityPlanningCount - 1, + 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: - evidence.delegationContractCount === 3 && + Number.isInteger(evidence.delegationContractCount) && + evidence.delegationContractCount >= 3 && evidence.delegationContractViolationCount === 0, }; } function expectedProviderStats(stats) { const responsibility = responsibilityContractEvidence(stats); - const qualityPlanningCount = responsibility.qualityPlanningCount; + const manifestReadyTasks = manifestReadyTaskEvidence(stats); return ( - (qualityPlanningCount === 1 || qualityPlanningCount === 2) && - stats.requestCount === 15 + qualityPlanningCount && - stats.planningRequestCount === 15 + qualityPlanningCount && + Number.isInteger(stats.requestCount) && + stats.requestCount >= 40 && + stats.planningRequestCount + + stats.finalReplyRequestCount + + (stats.contextCompactionRequestCount ?? 0) + + (stats.imageInspectionRequestCount ?? 0) === + stats.requestCount && stats.finalReplyRequestCount === 0 && - stats.initialDelegationCount === 2 && - stats.followupDelegationCount === 1 && - stats.runStatusCount === 2 && - stats.sourceWriteCount === 1 && - stats.sourcePatchCount === 1 && - stats.staticSmokeCount === 4 && - stats.previewValidationCount === 2 && - stats.supervisorDirectMutationAttemptCount === 1 && + stats.initialDelegationCount === 3 && + stats.followupDelegationCount === 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.imageInspectionRequestCount === 1 && + stats.canvasGenerationRequestCount === 2 && + stats.canvasDownloadRequestCount === 2 && + stats.canvasGeneratedAspectRatios?.['16:9'] === 1 && + stats.canvasGeneratedAspectRatios?.['1:1'] === 1 && + stats.canonicalCodeRunCount === 1 && stats.unexpectedRequestCount === 0 && Object.keys(stats.rejectionCodes ?? {}).length === 0 && - stats.byAgent?.['project-supervisor']?.planning === 9 && + stats.byAgent?.['project-supervisor']?.planning >= 5 && stats.byAgent?.['project-supervisor']?.finalReply === 0 && - stats.byAgent?.['project-supervisor']?.projectMutation === 1 && - stats.byAgent?.['code-prototype']?.planning === 6 && + stats.byAgent?.['project-supervisor']?.projectMutation === 0 && + stats.byAgent?.['code-prototype']?.planning >= 6 && stats.byAgent?.['code-prototype']?.finalReply === 0 && - stats.byAgent?.['code-prototype']?.projectMutation === 2 && + stats.byAgent?.['code-prototype']?.projectMutation >= 2 && stats.byAgent?.['quality-review']?.finalReply === 0 && responsibility.codeOwnsGameIndex && responsibility.qualityIsReadOnly && responsibility.qualityIndependentOfCodeTiming && + responsibility.artOwnsGeneratedAsset && responsibility.contractViolationFree ); } +function manifestReadyTaskEvidence(stats) { + const rawCounts = stats?.readyTaskCountsByAgent; + const byAgent = + rawCounts && typeof rawCounts === 'object' && !Array.isArray(rawCounts) + ? Object.fromEntries( + Object.entries(rawCounts) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([agentId, counts]) => [ + agentId, + { + run: counts?.run ?? null, + completion: counts?.completion ?? null, + }, + ]), + ) + : {}; + const actualAgentIds = Object.keys(byAgent); + const expectedAgentIds = [...deterministicManifestReadyAgentIds].sort(); + const exactAgentSet = + actualAgentIds.length === expectedAgentIds.length && + actualAgentIds.every( + (agentId, index) => agentId === expectedAgentIds[index], + ); + const runTotal = Object.values(byAgent).reduce( + (total, counts) => total + (Number.isInteger(counts.run) ? counts.run : 0), + 0, + ); + const completionTotal = Object.values(byAgent).reduce( + (total, counts) => + total + (Number.isInteger(counts.completion) ? counts.completion : 0), + 0, + ); + const perAgentExactlyOnce = + exactAgentSet && + expectedAgentIds.every( + (agentId) => + byAgent[agentId]?.run === 1 && byAgent[agentId]?.completion === 1, + ); + return { + expectedAgentIds, + byAgent, + runTotal, + completionTotal, + exactAgentSet, + perAgentExactlyOnce, + exactlyOnce: + perAgentExactlyOnce && + runTotal === deterministicManifestReadyAgentIds.length && + completionTotal === deterministicManifestReadyAgentIds.length && + stats?.manifestReadyTaskRunCount === + deterministicManifestReadyAgentIds.length && + stats?.manifestReadyTaskCompletionCount === + deterministicManifestReadyAgentIds.length, + }; +} + const requiredZeroChildEvidenceFields = Object.freeze([ 'activeRunnerKillCount', 'approveInputCount', @@ -216,6 +306,14 @@ const requiredZeroChildEvidenceFields = Object.freeze([ function expectedChildReport(report, options, providerStats) { const evidence = report?.evidence; const providerRequestCount = providerStats?.requestCount; + const runtimeProviderRequestCount = + Number.isInteger(providerRequestCount) && + Number.isInteger(providerStats?.interactionExecuteCount) && + Number.isInteger(providerStats?.imageInspectionRequestCount) + ? providerRequestCount - + providerStats.interactionExecuteCount - + providerStats.imageInspectionRequestCount + : null; return ( report?.status === 'PASS' && report?.suite === suite && @@ -241,11 +339,11 @@ function expectedChildReport(report, options, providerStats) { Number.isInteger(evidence?.projectRevisionDelta) && evidence.projectRevisionDelta > 0 && evidence?.finalSupervisorAssistantCount === 1 && - evidence?.professionalAssistantCount === 3 && - evidence?.providerRequestIdentityCount === providerRequestCount && - evidence?.providerLifecycleStartedCount === providerRequestCount && - evidence?.providerLifecycleTerminalCount === providerRequestCount && - evidence?.providerLifecycleCompletedCount === providerRequestCount && + evidence?.professionalAssistantCount >= 3 && + evidence?.providerRequestIdentityCount === runtimeProviderRequestCount && + evidence?.providerLifecycleStartedCount === runtimeProviderRequestCount && + evidence?.providerLifecycleTerminalCount === runtimeProviderRequestCount && + evidence?.providerLifecycleCompletedCount === runtimeProviderRequestCount && requiredZeroChildEvidenceFields.every((field) => evidence?.[field] === 0) ); } @@ -300,6 +398,12 @@ async function runSelfTest() { 'runtime_tool_file_patch', 'runtime_tool_file_write', 'runtime_tool_preview_validate', + 'runtime_tool_task_list', + ]; + const manifestReadyTools = [ + ...allTools, + 'runtime_tool_asset_list', + 'runtime_tool_file_read', ]; const route = (agentId, runId, tools = allTools, extraContext = '') => router.route({ @@ -312,11 +416,12 @@ async function runSelfTest() { ); assert( initialDelegationCalls.map((call) => call.name).join(',') === - 'runtime_tool_agent_delegate,runtime_tool_agent_delegate', + 'runtime_tool_agent_delegate,runtime_tool_agent_delegate,runtime_tool_agent_delegate', 'self-test-initial-delegation-invalid', ); 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) === @@ -328,7 +433,14 @@ async function runSelfTest() { '不要读取或依赖并行 code-prototype', ) && Array.isArray(initialQualityDelegation.expectedArtifacts) && - initialQualityDelegation.expectedArtifacts.length === 0, + 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 = @@ -351,7 +463,19 @@ async function runSelfTest() { ); assert( responseFunctionNames( - route('quality-review', 'quality-run', allTools, qualityPlan), + 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', ); @@ -391,28 +515,600 @@ async function runSelfTest() { assert( responseFunctionNames(route('project-supervisor', 'parent-run')).join( ',', - ) === 'runtime_tool_command_run_limited,runtime_tool_preview_validate', + ) === 'runtime_tool_task_list,runtime_tool_agent_run_status', + 'self-test-manifest-wait-invalid', + ); + assert( + responseFunctionNames( + route( + 'project-supervisor', + 'parent-run', + allTools, + '已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16', + ), + ).join(',') === + 'runtime_tool_command_run_limited,runtime_tool_preview_validate', 'self-test-repair-verification-batch-invalid', ); route('project-supervisor', 'parent-run'); + + for (const agentId of deterministicManifestReadyAgentIds) { + const runId = `manifest-ready-${agentId}`; + const extraContext = + `处理 manifest ready 任务:${agentId}\n` + `任务 ID:${agentId}`; + 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')) { + terminalCompletionCount += 1; + break; + } + } + assert( + terminalCompletionCount === 1, + `self-test-manifest-ready-terminal-invalid:${agentId}`, + ); + } const stats = router.getStats(); - assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid'); + const manifestReadyTasks = manifestReadyTaskEvidence(stats); + assert( + manifestReadyTasks.exactlyOnce, + 'self-test-manifest-ready-exactly-once-invalid', + ); + + const missingReadyTaskStats = structuredClone(stats); + const missingAgentId = deterministicManifestReadyAgentIds.at(-1); + delete missingReadyTaskStats.readyTaskCountsByAgent[missingAgentId]; + missingReadyTaskStats.manifestReadyTaskRunCount -= 1; + missingReadyTaskStats.manifestReadyTaskCompletionCount -= 1; + assert( + !manifestReadyTaskEvidence(missingReadyTaskStats).exactlyOnce, + 'self-test-manifest-ready-missing-accepted', + ); + + const duplicateReadyTaskStats = structuredClone(stats); + const duplicateAgentId = deterministicManifestReadyAgentIds[0]; + duplicateReadyTaskStats.readyTaskCountsByAgent[duplicateAgentId].completion += + 1; + duplicateReadyTaskStats.manifestReadyTaskCompletionCount += 1; + assert( + !manifestReadyTaskEvidence(duplicateReadyTaskStats).exactlyOnce, + 'self-test-manifest-ready-duplicate-accepted', + ); + + const retryObservationContext = (observations) => + `\n已有工具观察:\n${JSON.stringify(observations)}`; + const staleObservation = { + tool: 'runtime.verification', + status: 'blocked', + summary: '最终回复生成期间项目 revision 已变化', + detail: 'responseRevision=1, currentRevision=2', + }; + const successfulSmokeObservation = { + tool: 'command.run_limited', + status: 'ok', + summary: 'game.static_smoke 已完成', + detail: null, + }; + const blockedSmokeObservation = { + tool: 'command.run_limited', + status: 'blocked', + summary: '仓库规范或启动上下文已漂移,旧动作未执行', + detail: + 'repositoryContextDrift=true · 请在同一 run 下一轮 planning 重新确认适用规范', + }; + const projectRevisionDriftSmokeObservation = { + tool: 'command.run_limited', + status: 'blocked', + summary: '并行项目变更使旧动作过期,旧动作未执行', + detail: + 'projectRevisionDrift=true · expectedRevision=4 · currentRevision=5 · 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 oldSmokeRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const oldSmokeRoute = (extraContext = '') => + oldSmokeRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'code-prototype', + 'old-smoke-initial-code-run', + allTools, + extraContext, + ), + }); + oldSmokeRoute(); + oldSmokeRoute(); + oldSmokeRoute(); + 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', + ); + + const readOnlyCommandRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const readOnlyCommandRunId = 'read-only-command-only-ready-run'; + const readOnlyCommandRoute = (tools) => + readOnlyCommandRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'quality-review', + readOnlyCommandRunId, + tools, + '处理 manifest ready 任务:quality-review\n任务 ID:quality-review', + ), + }); + readOnlyCommandRoute(manifestReadyTools); + readOnlyCommandRoute(manifestReadyTools); + const readOnlyCommandCode = captureProviderErrorCode(() => + readOnlyCommandRoute(['runtime_tool_command_run_limited']), + ); + const readOnlyCommandStats = readOnlyCommandRouter.getStats(); + assert( + readOnlyCommandCode === + 'provider-ready-finalization-tools-invalid:quality-review:runtime_tool_command_run_limited' && + readOnlyCommandStats.readyTaskCountsByAgent['quality-review'] + ?.completion === 0 && + readOnlyCommandStats.manifestReadyTaskStaticSmokeCount === 0, + 'self-test-read-only-command-only-not-rejected', + ); + + const preCompletionRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const preCompletionAgentId = 'preview-readiness'; + const preCompletionRunId = 'pre-completion-transient-ready-run'; + const preCompletionRoute = (tools, extraContext = '') => + preCompletionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + preCompletionAgentId, + preCompletionRunId, + tools, + `处理 manifest ready 任务:${preCompletionAgentId}\n任务 ID:${preCompletionAgentId}${extraContext}`, + ), + }); + preCompletionRoute(manifestReadyTools); + for (let retry = 0; retry < 16; retry += 1) { + assert( + responseFunctionNames( + preCompletionRoute( + ['runtime_tool_command_run_limited'], + retryObservationContext([blockedSmokeObservation]), + ), + ).join(',') === 'runtime_tool_command_run_limited', + `self-test-ready-precompletion-transient-invalid:${retry}`, + ); + } + const preCompletionLimitCode = captureProviderErrorCode(() => + preCompletionRoute( + ['runtime_tool_command_run_limited'], + retryObservationContext([blockedSmokeObservation]), + ), + ); + const preCompletionStats = preCompletionRouter.getStats(); + assert( + preCompletionLimitCode === + `provider-ready-precompletion-verification-exhausted:${preCompletionAgentId}` && + preCompletionStats.readyTaskCountsByAgent[preCompletionAgentId] + ?.completion === 0 && + preCompletionStats.manifestReadyTaskStaticSmokeCount === 17, + '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])), + ); + assert( + deliveryCount === 16 && + limitCode === `provider-terminal-retry-exhausted:${agentId}`, + `self-test-initial-terminal-retry-limit-invalid:${agentId}`, + ); + }; + 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 = '') => + duplicateRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + duplicateAgentId, + runId, + manifestReadyTools, + `处理 manifest ready 任务:${duplicateAgentId}\n任务 ID:${duplicateAgentId}${extraContext}`, + ), + }); + duplicateRoute('duplicate-ready-run'); + duplicateRoute('duplicate-ready-run'); + let duplicateTerminalCode = null; + try { + duplicateRoute('duplicate-ready-run'); + } catch (error) { + duplicateTerminalCode = error?.code ?? null; + } + let duplicateRunCode = null; + try { + duplicateRoute('second-ready-run'); + } catch (error) { + duplicateRunCode = error?.code ?? null; + } + assert( + duplicateTerminalCode === + `provider-ready-run-terminal-duplicate:${duplicateAgentId}` && + duplicateRunCode === + `provider-ready-agent-run-duplicate:${duplicateAgentId}`, + 'self-test-manifest-ready-provider-duplicate-not-rejected', + ); + + const retryAgentId = 'preview-readiness'; + const retryRunId = 'retry-ready-run'; + const retryRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const retryRoute = (extraContext = '') => + retryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + retryAgentId, + retryRunId, + manifestReadyTools, + `处理 manifest ready 任务:${retryAgentId}\n任务 ID:${retryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if (responseFunctionNames(retryRoute()).includes('respond_to_user')) break; + } + for (let retry = 0; retry < 16; retry += 1) { + assert( + responseFunctionNames( + retryRoute(retryObservationContext([staleObservation])), + ).join(',') === 'runtime_tool_command_run_limited', + `self-test-ready-retry-verification-call-invalid:${retry}`, + ); + if (retry === 0) { + let missingObservationCode = null; + try { + retryRoute(); + } catch (error) { + missingObservationCode = error?.code ?? null; + } + assert( + missingObservationCode === + `provider-ready-retry-verification-invalid:${retryAgentId}`, + 'self-test-ready-retry-missing-observation-accepted', + ); + } + assert( + responseFunctionNames( + retryRoute(retryObservationContext([successfulSmokeObservation])), + ).includes('respond_to_user'), + `self-test-ready-retry-finalization-invalid:${retry}`, + ); + } + let retryLimitCode = null; + try { + retryRoute(retryObservationContext([staleObservation])); + } catch (error) { + retryLimitCode = error?.code ?? null; + } + const retryStats = retryRouter.getStats(); + assert( + retryLimitCode === + `provider-ready-run-terminal-duplicate:${retryAgentId}` && + retryStats.readyTaskCountsByAgent[retryAgentId]?.run === 1 && + retryStats.readyTaskCountsByAgent[retryAgentId]?.completion === 1, + 'self-test-ready-retry-limit-or-exactly-once-invalid', + ); + + const transientRetryRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const transientRetryRunId = 'transient-retry-ready-run'; + const transientRetryRoute = (extraContext = '') => + transientRetryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + retryAgentId, + transientRetryRunId, + manifestReadyTools, + `处理 manifest ready 任务:${retryAgentId}\n任务 ID:${retryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(transientRetryRoute()).includes('respond_to_user') + ) { + break; + } + } + transientRetryRoute(retryObservationContext([staleObservation])); + assert( + responseFunctionNames( + transientRetryRoute(retryObservationContext([blockedSmokeObservation])), + ).join(',') === 'runtime_tool_command_run_limited' && + responseFunctionNames( + transientRetryRoute( + retryObservationContext([successfulSmokeObservation]), + ), + ).includes('respond_to_user') && + transientRetryRouter.getStats().readyTaskCountsByAgent[retryAgentId] + ?.completion === 1, + 'self-test-ready-transient-verification-retry-invalid', + ); + + const standaloneRetryAgentId = 'design-foundation'; + const standaloneRetryRunId = 'standalone-retry-ready-run'; + const standaloneRetryRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const standaloneRetryRoute = (extraContext = '') => + standaloneRetryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + standaloneRetryAgentId, + standaloneRetryRunId, + manifestReadyTools, + `处理 manifest ready 任务:${standaloneRetryAgentId}\n任务 ID:${standaloneRetryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(standaloneRetryRoute()).includes('respond_to_user') + ) { + break; + } + } + const standaloneReplayResponse = standaloneRetryRoute( + retryObservationContext([successfulSmokeObservation]), + ); + const standaloneReplayDuplicateCode = captureProviderErrorCode(() => + standaloneRetryRoute(retryObservationContext([successfulSmokeObservation])), + ); + assert( + responseFunctionNames(standaloneReplayResponse).includes( + 'respond_to_user', + ) && + standaloneReplayDuplicateCode === + `provider-ready-run-terminal-duplicate:${standaloneRetryAgentId}` && + standaloneRetryRouter.getStats().readyTaskCountsByAgent[ + standaloneRetryAgentId + ]?.completion === 1, + 'self-test-ready-standalone-verification-replay-invalid', + ); + + const concurrentMutationAgentId = 'design-foundation'; + const concurrentMutationRunId = 'concurrent-mutation-ready-run'; + const concurrentMutationRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const concurrentMutationRoute = (extraContext = '') => + concurrentMutationRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + concurrentMutationAgentId, + concurrentMutationRunId, + manifestReadyTools, + `处理 manifest ready 任务:${concurrentMutationAgentId}\n任务 ID:${concurrentMutationAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(concurrentMutationRoute()).includes( + 'respond_to_user', + ) + ) { + break; + } + } + assert( + responseFunctionNames( + concurrentMutationRoute( + retryObservationContext([successfulProjectMutationObservation]), + ), + ).join(',') === 'runtime_tool_command_run_limited' && + responseFunctionNames( + concurrentMutationRoute( + retryObservationContext([successfulSmokeObservation]), + ), + ).includes('respond_to_user') && + concurrentMutationRouter.getStats().readyTaskCountsByAgent[ + concurrentMutationAgentId + ]?.completion === 1, + 'self-test-ready-concurrent-mutation-reverification-invalid', + ); + + const repairedWriterAgentId = 'balance-seed'; + const repairedWriterRunId = 'repaired-writer-ready-run'; + const repairedWriterRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const repairedWriterRoute = (tools = manifestReadyTools, extraContext = '') => + repairedWriterRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + repairedWriterAgentId, + repairedWriterRunId, + tools, + `处理 manifest ready 任务:${repairedWriterAgentId}\n任务 ID:${repairedWriterAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(repairedWriterRoute()).includes('respond_to_user') + ) { + break; + } + } + const repairedWriterMutation = repairedWriterRoute( + ['runtime_tool_file_write'], + retryObservationContext([successfulSmokeObservation]), + ); + const repairedWriterVerification = repairedWriterRoute( + manifestReadyTools, + retryObservationContext([successfulProjectMutationObservation]), + ); + const repairedWriterFinalization = repairedWriterRoute( + manifestReadyTools, + retryObservationContext([successfulSmokeObservation]), + ); + const repairedWriterDuplicateCode = captureProviderErrorCode(() => + repairedWriterRoute( + manifestReadyTools, + retryObservationContext([successfulSmokeObservation]), + ), + ); + assert( + responseFunctionNames(repairedWriterMutation).join(',') === + 'runtime_tool_file_write' && + responseFunctionNames(repairedWriterVerification).join(',') === + 'runtime_tool_command_run_limited' && + responseFunctionNames(repairedWriterFinalization).includes( + 'respond_to_user', + ) && + repairedWriterDuplicateCode === + `provider-ready-run-terminal-duplicate:${repairedWriterAgentId}` && + repairedWriterRouter.getStats().readyTaskCountsByAgent[ + repairedWriterAgentId + ]?.completion === 1, + 'self-test-ready-repaired-writer-reverification-invalid', + ); + + const projectRevisionDriftAgentId = 'preview-readiness'; + const projectRevisionDriftRunId = 'project-revision-drift-ready-run'; + const projectRevisionDriftRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const projectRevisionDriftRoute = (extraContext = '') => + projectRevisionDriftRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + projectRevisionDriftAgentId, + projectRevisionDriftRunId, + manifestReadyTools, + `处理 manifest ready 任务:${projectRevisionDriftAgentId}\n任务 ID:${projectRevisionDriftAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(projectRevisionDriftRoute()).includes( + 'respond_to_user', + ) + ) { + break; + } + } + assert( + responseFunctionNames( + projectRevisionDriftRoute( + retryObservationContext([projectRevisionDriftSmokeObservation]), + ), + ).join(',') === 'runtime_tool_command_run_limited' && + responseFunctionNames( + projectRevisionDriftRoute( + retryObservationContext([successfulSmokeObservation]), + ), + ).includes('respond_to_user') && + projectRevisionDriftRouter.getStats().readyTaskCountsByAgent[ + projectRevisionDriftAgentId + ]?.completion === 1, + 'self-test-ready-project-revision-drift-retry-invalid', + ); + + const readOnlyRetryAgentId = 'quality-review'; + const readOnlyRetryRunId = 'read-only-retry-ready-run'; + const readOnlyRetryRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const readOnlyRetryRoute = (extraContext = '') => + readOnlyRetryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + readOnlyRetryAgentId, + readOnlyRetryRunId, + manifestReadyTools, + `处理 manifest ready 任务:${readOnlyRetryAgentId}\n任务 ID:${readOnlyRetryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(readOnlyRetryRoute()).includes('respond_to_user') + ) { + break; + } + } + assert( + responseFunctionNames( + readOnlyRetryRoute(retryObservationContext([staleObservation])), + ).includes('respond_to_user') && + 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', ); - const singlePassQualityStats = structuredClone(stats); - singlePassQualityStats.requestCount -= 1; - singlePassQualityStats.planningRequestCount -= 1; - singlePassQualityStats.qualityRevisionReplanCount = 0; - singlePassQualityStats.byAgent['quality-review'].planning = 1; assert( - expectedProviderStats(singlePassQualityStats), - 'self-test-quality-single-pass-timing-invalid', + stats.initialDelegationCount === 3 && + stats.followupDelegationCount === 1 && + stats.supervisorDirectMutationAttemptCount === 1 && + stats.unexpectedRequestCount === 0, + 'self-test-provider-core-stats-invalid', ); const syntheticChildReport = { @@ -492,6 +1188,19 @@ async function runSelfTest() { providerUsed: false, htmlChars: [...html].length, providerStats: stats, + manifestReadyTasks, + manifestReadyFailureSamplesValidated: ['missing', 'duplicate'], + manifestReadyProviderDuplicateRejections: { + terminal: duplicateTerminalCode, + run: duplicateRunCode, + }, + terminalRetryContractsValidated: { + oldSmokeObservationSingleUse: true, + readOnlyCommandOnlyRejected: true, + preCompletionTransientRetryLimit: 16, + initialCodeRetryLimit: 16, + initialArtRetryLimit: 16, + }, responsibilityContract, qualityTimingOrdersValidated: ['before-code', 'after-code'], childHardGatesValidated: true, @@ -520,6 +1229,10 @@ async function runE2e(options) { ); provider = await startDeterministicLaneDefenseProvider({ apiKey }); const config = { + editorApi: { + apiKey, + baseUrl: provider.editorBaseUrl, + }, llm: { apiKey, baseUrl: provider.baseUrl, @@ -584,6 +1297,7 @@ async function runE2e(options) { expectedChildReport(childReport, options, providerStats); const providerPassed = providerStats?.stopped === true && expectedProviderStats(providerStats); + const manifestReadyTasks = manifestReadyTaskEvidence(providerStats); const responsibilityContract = responsibilityContractEvidence(providerStats); const status = !failureCode && childPassed && providerPassed && configRemoved @@ -603,6 +1317,7 @@ async function runE2e(options) { delegatedSuite: suite, child: childReport, provider: providerStats, + manifestReadyTasks, responsibilityContract, cleanup: { providerStopped: providerStats?.stopped === true, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs index 57bf51173..9937fffd4 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs @@ -246,7 +246,7 @@ export const runProfileBindingSchemaVersion = 'game-creator-run-profile-binding.v1'; export const autonomousCompletionContractSchemaVersion = - 'game-creator-autonomous-completion-contract.v1'; + 'game-creator-autonomous-completion-contract.v2'; export const autonomousPlaytestReceiptSchemaVersion = 'game-creator-autonomous-playtest-receipt.v1'; 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 46728bf27..7d0acb8f7 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 @@ -1028,6 +1028,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( taskSha256: contract.taskSha256, baselineRevision: contract.baselineRevision, baselineIndexSha256: contract.baselineIndexSha256, + baselineArtifacts: contract.baselineArtifacts, playtestScenario: contract.playtestScenario, createdAt: contract.createdAt, }; @@ -1040,6 +1041,13 @@ export async function validateSupervisorAutonomousPlayableEvidence( contract.taskSha256 === hashValue(task) && contract.baselineIndexSha256 === state.supervisorAutonomousPlayable.initialGameIndexSha256 && + Array.isArray(contract.baselineArtifacts) && + contract.baselineArtifacts.length === 1 && + contract.baselineArtifacts[0]?.path === 'game/index.html' && + contract.baselineArtifacts[0]?.sha256 === + state.supervisorAutonomousPlayable.initialGameIndexSha256 && + Number.isInteger(contract.baselineArtifacts[0]?.sizeBytes) && + contract.baselineArtifacts[0].sizeBytes > 0 && contract.playtestScenario === 'lane-defense-v1' && contract.contractFingerprint === hashJsonValue(contractIdentity), 'supervisor-autonomous-playable-completion-contract-invalid', diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 8cf4173e4..d10cfdcac 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -7,6 +7,7 @@ import { lstat, mkdir, mkdtemp, + open, readdir, readFile, realpath, @@ -15,7 +16,9 @@ import { } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { inflateSync } from 'node:zlib'; export const appIdentifier = 'world.genarrative.ai-game-creator'; export const configFileName = 'game-creator.config.json'; @@ -31,21 +34,111 @@ export const testRuntimeConfigSentinelSchema = 'genarrative-agc-swarm-test-config.v1'; export const ungeneratedGameEntryMarker = '还没有生成游戏。回到聊天输入创意并确认生成后'; +export const defaultRealSwarmTestTask = + '制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。'; +export const swarmTurnReportPrefix = '[turn.report] '; +export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1'; + +const swarmTurnReportKeys = [ + 'schemaVersion', + 'outcome', + 'parentAgentId', + 'sessionId', + 'parentRunId', + 'runtimeCount', + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'newAssistantMessageCount', + 'finalReplyChars', + 'reconciliationAgentCount', +].sort(); +const settledZeroCountFields = [ + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'reconciliationAgentCount', +]; + +const requiredFormalArtifactSpecs = [ + { path: 'memory/project.md', kind: 'file' }, + { path: 'game/game_design.md', kind: 'file' }, + { path: 'game/balance.json', kind: 'json' }, + { path: 'assets/manifest.art.json', kind: 'json' }, + { path: 'assets/manifest.audio.json', kind: 'json' }, + { path: 'game/index.html', kind: 'game-entry' }, + { path: 'exports/README.md', kind: 'file' }, +]; +const editorImageArtifactSpecs = [ + { path: 'assets/ui-prototype.png', kind: 'image', aspectRatio: 16 / 9 }, + { path: 'assets/art-spritesheet.png', kind: 'image', aspectRatio: 1 }, +]; +export const requiredSwarmManifestTaskIds = Object.freeze([ + 'design-director', + 'design-foundation', + 'balance-director', + 'balance-seed', + 'art-director', + 'art-asset-plan', + 'art-polish', + 'audio-director', + 'audio-asset-plan', + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + 'preview-playtest', + 'publish-strategy', + 'publish-package', +]); const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); +const configWizardPath = path.join( + appRoot, + 'scripts', + 'game-creator-config-wizard.mjs', +); const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; +const childTerminationGraceMs = 10_000; +const childForceTerminationWaitMs = 5_000; +const runnerShutdownTimeoutMs = 20_000; +const cleanupDirectoryTimeoutMs = 10_000; +const maximumValidatedPngBytes = 64 * 1024 * 1024; +const maximumValidatedPngPixels = 100_000_000; +const maximumInflatedPngBytes = 256 * 1024 * 1024; +const minimumMarkdownBodyCharacters = 24; +const minimumHtmlCharacters = 120; +const incompleteArtifactTextPattern = + /\b(?:todo|tbd|placeholder|coming[\t ]+soon|lorem[\t ]+ipsum)\b|待补充|待完善|占位|尚未完成|稍后补充|待填写|待验证|待复核|待确认|待定/iu; +const uncheckedMarkdownChecklistPattern = + /^[\t ]*(?:>[\t ]*)*(?:[-+*]|\d+[.)])[\t ]+\[[\t ]\](?:[\t ]|$)/mu; -export const usage = `用法:npm run agc:test:chat -- [选项] +export function hasIncompleteArtifactMarker(content) { + return ( + incompleteArtifactTextPattern.test(content) || + uncheckedMarkdownChecklistPattern.test(content) + ); +} + +export const usage = `用法: + npm run agc:test:chat + npm run agc:test:chat:manual -- [选项] 自动读取客户端 AppData 配置并复制到隔离目录,创建一次性项目后进入 Project Supervisor 自主测试。 -Swarm 完成后启动本地试玩;按 Ctrl+C 停止预览并清理一次性项目。 +带 --task 时完成正式产物验收后自动退出;手工聊天模式完成后启动持续预览。 选项: --config-dir <绝对路径> 显式指定客户端 AppData 配置来源目录 --project-dir <绝对路径> 使用已有项目或空目录,不自动删除 --keep-project 保留自动创建的一次性项目 - --no-open 启动预览但不自动打开浏览器 + --no-open 手工模式启动预览但不自动打开浏览器 + --task <需求> 通过 manual 入口非交互提交自定义需求 + --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM -h, --help 显示帮助`; @@ -57,12 +150,26 @@ function readOptionValue(args, index, option) { return value; } +function readTimeoutMinutes(args, index, option) { + const value = readOptionValue(args, index, option); + if (!/^[1-9]\d*$/u.test(value)) { + throw new Error(`${option} 必须是 1-1440 的整数分钟`); + } + const minutes = Number(value); + if (!Number.isSafeInteger(minutes) || minutes > 1_440) { + throw new Error(`${option} 必须是 1-1440 的整数分钟`); + } + return minutes; +} + export function parseSwarmTestArguments(args) { const options = { configDir: null, projectDir: null, keepProject: false, openBrowser: true, + task: null, + timeoutMinutes: null, dryRun: false, help: false, }; @@ -80,6 +187,18 @@ export function parseSwarmTestArguments(args) { options.keepProject = true; } else if (argument === '--no-open') { options.openBrowser = false; + } else if (argument === '--task') { + if (options.task) throw new Error('--task 只能指定一次'); + const task = readOptionValue(args, index, argument); + if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符'); + options.task = task; + index += 1; + } else if (argument === '--timeout-minutes') { + if (options.timeoutMinutes !== null) { + throw new Error('--timeout-minutes 只能指定一次'); + } + options.timeoutMinutes = readTimeoutMinutes(args, index, argument); + index += 1; } else if (argument === '--dry-run') { options.dryRun = true; } else if (argument === '--help' || argument === '-h') { @@ -91,6 +210,15 @@ export function parseSwarmTestArguments(args) { return options; } +export function shouldStartPersistentPreview(options) { + return !options.task; +} + +export function resolveSwarmTestTimeoutMs(options) { + const minutes = options.timeoutMinutes ?? (options.task ? 50 : null); + return minutes === null ? null : minutes * 60_000; +} + function pushUnique(values, value) { if (value && !values.includes(value)) values.push(value); } @@ -173,10 +301,70 @@ export async function discoverRuntimeConfigDir( ); } throw new Error( - `未找到客户端 AppData 配置。请先运行 npm run agc,在“运行时配置”中保存 LLM Provider,或传入 --config-dir。`, + `未找到客户端 AppData 配置。请运行 npm run agc:config,或启动 npm run agc 后在“运行时配置”中保存 LLM Provider。`, ); } +export function canPromptForMissingRuntimeConfig( + stdinIsTty = process.stdin.isTTY, + stdoutIsTty = process.stdout.isTTY, +) { + return Boolean(stdinIsTty && stdoutIsTty); +} + +async function askToConfigureMissingRuntime() { + if (!canPromptForMissingRuntimeConfig()) return false; + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = ( + await readline.question( + '未找到客户端 AppData 配置,是否现在进入安全配置向导? [Y/n]: ', + ) + ) + .trim() + .toLowerCase(); + return !answer || ['y', 'yes', '是'].includes(answer); + } finally { + readline.close(); + } +} + +export function buildMissingConfigWizardArguments(explicitConfigDir) { + return [ + configWizardPath, + '--configure-only', + ...(explicitConfigDir ? ['--config-dir', explicitConfigDir] : []), + ]; +} + +async function runMissingConfigWizard(setActiveChild, explicitConfigDir) { + const child = spawnChild( + process.execPath, + buildMissingConfigWizardArguments(explicitConfigDir), + { + stdio: 'inherit', + }, + ); + setActiveChild(child); + const result = await childExit(child); + setActiveChild(null); + if (result.code !== 0 || result.signal) { + throw new Error( + `配置向导未正常完成:code=${result.code ?? ''} signal=${result.signal ?? ''}`, + ); + } +} + +async function secureWindowsPrivateRuntimePath(targetPath, options) { + const { secureWindowsGameCreatorPathForCurrentUser } = await import( + './game-creator-config-wizard.mjs' + ); + await secureWindowsGameCreatorPathForCurrentUser(targetPath, options); +} + async function copyPrivateRuntimeConfigEntry( sourceConfigDir, runtimeConfigDir, @@ -197,8 +385,22 @@ async function copyPrivateRuntimeConfigEntry( } const destinationPath = path.join(runtimeConfigDir, fileName); - await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL); - if (process.platform !== 'win32') await chmod(destinationPath, 0o600); + if (process.platform === 'win32') { + const sourceBytes = await readFile(sourcePath); + const destinationFile = await open(destinationPath, 'wx', 0o600); + try { + await secureWindowsPrivateRuntimePath(destinationPath, { + isDirectory: false, + }); + await destinationFile.writeFile(sourceBytes); + await destinationFile.sync(); + } finally { + await destinationFile.close(); + } + } else { + await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL); + await chmod(destinationPath, 0o600); + } const [sourceMetadataAfterCopy, destinationMetadata] = await Promise.all([ lstat(sourcePath), lstat(destinationPath), @@ -241,7 +443,13 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) { path.join(canonicalTempRoot, testRuntimeConfigPrefix), ); try { - if (process.platform !== 'win32') await chmod(runtimeConfigDir, 0o700); + if (process.platform === 'win32') { + await secureWindowsPrivateRuntimePath(runtimeConfigDir, { + isDirectory: true, + }); + } else { + await chmod(runtimeConfigDir, 0o700); + } const sentinelToken = randomUUID(); await writeFile( path.join(runtimeConfigDir, testRuntimeConfigSentinelName), @@ -275,6 +483,42 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) { } } +const cleanupDirectoryChildProgram = String.raw` +const { rm } = require('node:fs/promises'); +const target = process.argv[1]; +rm(target, { recursive: true, force: false }).catch((error) => { + process.stderr.write(String(error && error.message || error)); + process.exitCode = 1; +}); +`; + +export async function removeDirectoryWithTimeout( + directoryPath, + { + timeoutMs = cleanupDirectoryTimeoutMs, + childProgram = cleanupDirectoryChildProgram, + } = {}, +) { + const child = spawnChild( + process.execPath, + ['-e', childProgram, directoryPath], + { + stdio: ['ignore', 'ignore', 'ignore'], + }, + ); + const result = await childExitWithTimeout( + child, + timeoutMs, + `清理目录 ${path.basename(directoryPath)}`, + { graceMs: 1_000, forceWaitMs: 2_000 }, + ); + if (result.code !== 0 || result.signal) { + throw new Error( + `清理目录失败:${path.basename(directoryPath)} code=${result.code ?? ''} signal=${result.signal ?? ''}`, + ); + } +} + export async function cleanupSwarmTestRuntimeConfig(runtimeConfig) { if (!runtimeConfig?.owned) return false; const sentinelPath = path.join( @@ -313,7 +557,7 @@ export async function cleanupSwarmTestRuntimeConfig(runtimeConfig) { if (endpointMetadata) { throw new Error('拒绝清理:隔离 Agent Runner 尚未退出'); } - await rm(canonical, { recursive: true, force: false }); + await removeDirectoryWithTimeout(canonical); return true; } @@ -397,7 +641,7 @@ export async function cleanupSwarmTestProject(project) { ) { throw new Error('拒绝清理:一次性项目目录身份不匹配'); } - await rm(canonical, { recursive: true, force: false }); + await removeDirectoryWithTimeout(canonical); return true; } @@ -409,18 +653,232 @@ function spawnChild(command, args, options = {}) { return spawn(command, args, { cwd: appRoot, env: process.env, + detached: process.platform !== 'win32', ...options, }); } +const childExitPromises = new WeakMap(); +const closedChildren = new WeakSet(); + function childExit(child) { - return new Promise((resolve, reject) => { + const existing = childExitPromises.get(child); + if (existing) return existing; + const exitPromise = new Promise((resolve, reject) => { child.once('error', reject); - child.once('exit', (code, signal) => resolve({ code, signal })); + child.once('close', (code, signal) => { + closedChildren.add(child); + resolve({ code, signal }); + }); }); + childExitPromises.set(child, exitPromise); + return exitPromise; } -async function runCapturedCargo(cliArguments, setActiveChild) { +async function childExitWithin(exitPromise, timeoutMs) { + let timeoutHandle; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve(null), timeoutMs); + }); + try { + return await Promise.race([exitPromise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } +} + +export async function terminateChildTree( + child, + signal = 'SIGTERM', + force = false, +) { + if (!child || closedChildren.has(child) || !Number.isInteger(child.pid)) { + return; + } + if (process.platform === 'win32') { + const taskkill = spawn( + 'taskkill.exe', + ['/PID', String(child.pid), '/T', ...(force ? ['/F'] : [])], + { + stdio: 'ignore', + windowsHide: true, + }, + ); + const taskkillExit = childExit(taskkill).catch(() => null); + if (!(await childExitWithin(taskkillExit, childForceTerminationWaitMs))) { + try { + taskkill.kill('SIGKILL'); + } catch { + // The taskkill helper may have exited at the timeout boundary. + } + await childExitWithin(taskkillExit, childForceTerminationWaitMs); + } + return; + } + try { + process.kill(-child.pid, force ? 'SIGKILL' : signal); + } catch (error) { + try { + child.kill(force ? 'SIGKILL' : signal); + } catch { + if (error?.code !== 'ESRCH') throw error; + } + } +} + +async function terminateChildTreeAndWait( + child, + exitPromise, + signal, + label, + { + graceMs = childTerminationGraceMs, + forceWaitMs = childForceTerminationWaitMs, + } = {}, +) { + await terminateChildTree(child, signal, false); + const gracefulResult = await childExitWithin(exitPromise, graceMs); + if (gracefulResult) return gracefulResult; + + await terminateChildTree(child, 'SIGKILL', true); + const forcedResult = await childExitWithin(exitPromise, forceWaitMs); + if (forcedResult) return forcedResult; + throw new Error(`${label} 无法在强制终止进程树后关闭 stdio`); +} + +export async function childExitWithTimeout( + child, + timeoutMs, + label, + terminationOptions, +) { + const exitPromise = childExit(child); + if (timeoutMs === null) return exitPromise; + let timeoutHandle; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + }); + try { + const first = await Promise.race([ + exitPromise.then((result) => ({ kind: 'exit', result })), + timeoutPromise, + ]); + if (first.kind === 'exit') return first.result; + try { + await terminateChildTreeAndWait( + child, + exitPromise, + 'SIGTERM', + label, + terminationOptions, + ); + } catch (error) { + error.code = 'AGC_CHILD_TIMEOUT'; + throw error; + } + throw Object.assign( + new Error(`${label} 超过 ${Math.ceil(timeoutMs / 1000)} 秒期限`), + { code: 'AGC_CHILD_TIMEOUT' }, + ); + } finally { + clearTimeout(timeoutHandle); + } +} + +function isPlainObject(value) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function isNonNegativeSafeInteger(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +export function parseSettledSwarmTurnReport(output) { + const reportLines = String(output) + .split(/\r?\n/u) + .filter((line) => line.startsWith(swarmTurnReportPrefix)); + if (reportLines.length !== 1) { + throw new Error( + `Agent Swarm 终态报告数量无效:expected=1 actual=${reportLines.length}`, + ); + } + + let report; + try { + report = JSON.parse(reportLines[0].slice(swarmTurnReportPrefix.length)); + } catch { + throw new Error('Agent Swarm 终态报告不是有效 JSON'); + } + if ( + !isPlainObject(report) || + JSON.stringify(Object.keys(report).sort()) !== + JSON.stringify(swarmTurnReportKeys) + ) { + throw new Error('Agent Swarm 终态报告结构无效'); + } + if (report.schemaVersion !== swarmTurnReportSchema) { + throw new Error('Agent Swarm 终态报告 schema 无效'); + } + if ( + typeof report.parentAgentId !== 'string' || + !report.parentAgentId.trim() || + typeof report.sessionId !== 'string' || + !report.sessionId.trim() || + typeof report.parentRunId !== 'string' || + !report.parentRunId.trim() + ) { + throw new Error('Agent Swarm 终态报告运行身份无效'); + } + const countFields = [ + 'runtimeCount', + ...settledZeroCountFields, + 'newAssistantMessageCount', + 'finalReplyChars', + ]; + if (countFields.some((field) => !isNonNegativeSafeInteger(report[field]))) { + throw new Error('Agent Swarm 终态报告计数无效'); + } + if ( + !['settled', 'failed', 'incomplete', 'needs-reconciliation'].includes( + report.outcome, + ) + ) { + throw new Error('Agent Swarm 终态报告 outcome 无效'); + } + if (report.outcome !== 'settled') { + throw new Error(`Agent Swarm 本轮未收束:outcome=${report.outcome}`); + } + const unsettledField = settledZeroCountFields.find( + (field) => report[field] !== 0, + ); + if (unsettledField) { + throw new Error( + `Agent Swarm 本轮仍有未收束工作:${unsettledField}=${report[unsettledField]}`, + ); + } + if (report.runtimeCount < 1) { + throw new Error('Agent Swarm 终态报告没有 Runtime'); + } + if ( + report.newAssistantMessageCount !== 1 || + report.finalReplyChars < 1 || + report.finalReplyChars > 1_000_000 + ) { + throw new Error('Agent Swarm 最终回复无效'); + } + return report; +} + +async function runCapturedCargo( + cliArguments, + setActiveChild, + { timeoutMs = null, label = 'Cargo 子命令' } = {}, +) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { stdio: ['ignore', 'pipe', 'pipe'], }); @@ -435,9 +893,12 @@ async function runCapturedCargo(cliArguments, setActiveChild) { child.stderr.on('data', (chunk) => { stderr += chunk; }); - const result = await childExit(child); - setActiveChild(null); - return { ...result, stdout, stderr }; + try { + const result = await childExitWithTimeout(child, timeoutMs, label); + return { ...result, stdout, stderr }; + } finally { + setActiveChild(null); + } } async function runInteractiveCargo(cliArguments, setActiveChild) { @@ -450,6 +911,45 @@ async function runInteractiveCargo(cliArguments, setActiveChild) { return result; } +async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) { + const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { + stdio: ['pipe', 'pipe', 'inherit'], + }); + setActiveChild(child); + const reportLines = []; + let pendingLine = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + process.stdout.write(chunk); + pendingLine += chunk; + const lines = pendingLine.split('\n'); + pendingLine = lines.pop() ?? ''; + for (const line of lines) { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line; + if (normalizedLine.startsWith(swarmTurnReportPrefix)) { + reportLines.push(normalizedLine); + } + } + }); + child.stdin.end(`${task}\n`); + try { + const result = await childExitWithTimeout( + child, + timeoutMs, + 'Agent Swarm 自动任务', + ); + const normalizedPendingLine = pendingLine.endsWith('\r') + ? pendingLine.slice(0, -1) + : pendingLine; + if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) { + reportLines.push(normalizedPendingLine); + } + return { ...result, turnReportOutput: reportLines.join('\n') }; + } finally { + setActiveChild(null); + } +} + export function parseRunnerShutdownOutput(output) { const match = output.match(/^runner\.stopped=(true|false)$/m); if (!match) throw new Error('Runner 收束命令缺少 stopped 状态'); @@ -460,6 +960,10 @@ async function shutdownSwarmTestRunner(runtimeConfig, setActiveChild) { const result = await runCapturedCargo( ['--config-dir', runtimeConfig.path, '--runner-shutdown-if-idle'], setActiveChild, + { + timeoutMs: runnerShutdownTimeoutMs, + label: '隔离 Agent Runner 收束命令', + }, ); if (result.code !== 0 || result.signal) { throw new Error( @@ -493,6 +997,671 @@ export async function hasGeneratedGameEntry(projectPath) { return html.trim().length > 0 && !html.includes(ungeneratedGameEntryMarker); } +const pngSignature = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const pngCrcTable = Uint32Array.from({ length: 256 }, (_unused, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +function pngCrc32(bytes) { + let value = 0xffffffff; + for (const byte of bytes) { + value = pngCrcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + } + return (value ^ 0xffffffff) >>> 0; +} + +export function validatePngBytes(bytes) { + if ( + !Buffer.isBuffer(bytes) || + bytes.length < 57 || + bytes.length > maximumValidatedPngBytes || + !bytes.subarray(0, pngSignature.length).equals(pngSignature) + ) { + throw new Error('PNG 签名或文件大小无效'); + } + + let offset = pngSignature.length; + let ihdr = null; + let ihdrCount = 0; + let idatSeen = false; + let idatEnded = false; + let iendSeen = false; + let plteCount = 0; + let plteEntries = 0; + const idatChunks = []; + while (offset < bytes.length) { + if (bytes.length - offset < 12) throw new Error('PNG chunk 被截断'); + const length = bytes.readUInt32BE(offset); + const typeOffset = offset + 4; + const dataOffset = typeOffset + 4; + const dataEnd = dataOffset + length; + const chunkEnd = dataEnd + 4; + if (dataEnd > bytes.length - 4 || chunkEnd > bytes.length) { + throw new Error('PNG chunk 长度越界'); + } + const type = bytes.subarray(typeOffset, dataOffset).toString('ascii'); + if (!/^[A-Za-z]{4}$/u.test(type)) throw new Error('PNG chunk 类型无效'); + const expectedCrc = bytes.readUInt32BE(dataEnd); + const actualCrc = pngCrc32(bytes.subarray(typeOffset, dataEnd)); + if (expectedCrc !== actualCrc) throw new Error(`${type} CRC 无效`); + + const data = bytes.subarray(dataOffset, dataEnd); + if (offset === pngSignature.length && type !== 'IHDR') { + throw new Error('IHDR 必须是第一个 chunk'); + } + if (type === 'IHDR') { + ihdrCount += 1; + if (ihdrCount !== 1 || length !== 13) { + throw new Error('IHDR 数量或长度无效'); + } + ihdr = { + width: data.readUInt32BE(0), + height: data.readUInt32BE(4), + bitDepth: data[8], + colorType: data[9], + compression: data[10], + filter: data[11], + interlace: data[12], + }; + } else if (type === 'PLTE') { + plteCount += 1; + if ( + !ihdr || + idatSeen || + iendSeen || + plteCount !== 1 || + length < 3 || + length > 768 || + length % 3 !== 0 + ) { + throw new Error('PLTE 数量、长度或顺序无效'); + } + plteEntries = length / 3; + } else if (type === 'IDAT') { + if (!ihdr || idatEnded || iendSeen) { + throw new Error('IDAT 顺序无效'); + } + idatSeen = true; + idatChunks.push(data); + } else if (type === 'IEND') { + if (!ihdr || !idatSeen || iendSeen || length !== 0) { + throw new Error('IEND 数量、长度或顺序无效'); + } + iendSeen = true; + if (chunkEnd !== bytes.length) throw new Error('IEND 后存在额外数据'); + } else { + if ((type.charCodeAt(0) & 0x20) === 0) { + throw new Error(`不支持的 PNG critical chunk:${type}`); + } + if (idatSeen) idatEnded = true; + } + offset = chunkEnd; + } + + if (!ihdr || ihdrCount !== 1 || !idatSeen || !iendSeen) { + throw new Error('PNG 缺少唯一 IHDR、IDAT 或 IEND'); + } + const validBitDepths = { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], + }; + if ( + ihdr.width < 1 || + ihdr.height < 1 || + ihdr.width * ihdr.height > maximumValidatedPngPixels || + !validBitDepths[ihdr.colorType]?.includes(ihdr.bitDepth) || + ihdr.compression !== 0 || + ihdr.filter !== 0 || + ihdr.interlace !== 0 + ) { + throw new Error('IHDR 参数无效或不支持交错 PNG'); + } + if ( + (ihdr.colorType === 3 && + (plteCount !== 1 || plteEntries > 2 ** ihdr.bitDepth)) || + ([0, 4].includes(ihdr.colorType) && plteCount !== 0) + ) { + throw new Error('PLTE 与 PNG color type 或 bit depth 不匹配'); + } + + const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ihdr.colorType]; + const rowBytes = Math.ceil((ihdr.width * channels * ihdr.bitDepth) / 8); + const expectedInflatedBytes = ihdr.height * (rowBytes + 1); + if ( + !Number.isSafeInteger(expectedInflatedBytes) || + expectedInflatedBytes > maximumInflatedPngBytes + ) { + throw new Error('PNG scanline 大小无效'); + } + let inflated; + try { + const compressed = Buffer.concat(idatChunks); + const result = inflateSync(compressed, { + maxOutputLength: expectedInflatedBytes + 1, + info: true, + }); + if (result.engine.bytesWritten !== compressed.length) { + throw new Error('trailing compressed bytes'); + } + inflated = result.buffer; + } catch { + throw new Error('IDAT zlib 数据无法完整解压'); + } + if (inflated.length !== expectedInflatedBytes) { + throw new Error('非交错 PNG scanline 长度无效'); + } + for (let row = 0; row < ihdr.height; row += 1) { + if (inflated[row * (rowBytes + 1)] > 4) { + throw new Error(`第 ${row + 1} 行 filter byte 无效`); + } + } + return { width: ihdr.width, height: ihdr.height }; +} + +async function validatePngFile(filePath) { + const metadata = await lstat(filePath); + if ( + !metadata.isFile() || + metadata.isSymbolicLink() || + metadata.size < 1_024 || + metadata.size > maximumValidatedPngBytes + ) { + throw new Error('PNG 文件缺失、类型无效或大小超限'); + } + const noFollowFlag = + process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0); + const file = await open(filePath, fsConstants.O_RDONLY | noFollowFlag); + try { + return validatePngBytes(await file.readFile()); + } finally { + await file.close(); + } +} + +async function inspectFormalArtifact(projectPath, spec) { + const artifactPath = path.join(projectPath, ...spec.path.split('/')); + let metadata; + try { + metadata = await lstat(artifactPath); + } catch (error) { + return { + path: spec.path, + reason: + error?.code === 'ENOENT' || error?.code === 'ENOTDIR' + ? '缺失' + : '无法安全读取', + }; + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + return { path: spec.path, reason: '不是无符号链接普通文件' }; + } + if (metadata.size === 0) return { path: spec.path, reason: '空文件' }; + + const noFollowFlag = + process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0); + let file; + try { + file = await open(artifactPath, fsConstants.O_RDONLY | noFollowFlag); + } catch { + return { path: spec.path, reason: '无法安全读取' }; + } + try { + const openedMetadata = await file.stat(); + if (!openedMetadata.isFile()) { + return { path: spec.path, reason: '不是无符号链接普通文件' }; + } + if (openedMetadata.size === 0) { + return { path: spec.path, reason: '空文件' }; + } + + if (spec.kind === 'json') { + let parsed; + try { + parsed = JSON.parse(await file.readFile('utf8')); + } catch { + return { path: spec.path, reason: 'JSON 无法解析' }; + } + if (!isPlainObject(parsed) || Object.keys(parsed).length === 0) { + return { path: spec.path, reason: 'JSON 必须是非空对象' }; + } + } else if (spec.kind === 'file' || spec.kind === 'game-entry') { + const content = await file.readFile('utf8'); + if (content.trim().length === 0) { + return { path: spec.path, reason: '空文件' }; + } + if (hasIncompleteArtifactMarker(content)) { + return { path: spec.path, reason: '仍包含占位标记' }; + } + const compactContent = content.replace(/\s/gu, ''); + if ( + spec.kind === 'file' && + compactContent.length < minimumMarkdownBodyCharacters + ) { + return { path: spec.path, reason: 'Markdown 正文过短' }; + } + if ( + spec.kind === 'game-entry' && + content.includes(ungeneratedGameEntryMarker) + ) { + return { path: spec.path, reason: '仍是初始化占位页' }; + } + if ( + spec.kind === 'game-entry' && + (compactContent.length < minimumHtmlCharacters || + !/]*>[\s\S]*<\/html>/iu.test(content) || + !/ maximumValidatedPngBytes) { + return { path: spec.path, reason: '图片文件大小超限' }; + } + let dimensions; + try { + dimensions = validatePngBytes(await file.readFile()); + } catch (error) { + return { + path: spec.path, + reason: `PNG 文件无效:${error.message}`, + }; + } + const ratio = dimensions.width / dimensions.height; + if (Math.abs(ratio - spec.aspectRatio) > 0.03) { + return { + path: spec.path, + reason: `图片比例无效:${dimensions.width}x${dimensions.height}`, + }; + } + } + } catch { + return { path: spec.path, reason: '无法安全读取' }; + } finally { + await file.close(); + } + return null; +} + +function resolveProjectEvidencePath(projectPath, relativePath) { + if ( + typeof relativePath !== 'string' || + !relativePath || + path.isAbsolute(relativePath) || + relativePath.includes('\\') + ) { + throw new Error('证据路径无效'); + } + const root = path.resolve(projectPath); + const targetPath = path.resolve(root, ...relativePath.split('/')); + if (!targetPath.startsWith(`${root}${path.sep}`)) { + throw new Error('证据路径越界'); + } + return targetPath; +} + +async function readSafeJson(projectPath, relativePath, maximumBytes) { + const targetPath = resolveProjectEvidencePath(projectPath, relativePath); + const metadata = await lstat(targetPath).catch(() => null); + if ( + !metadata?.isFile() || + metadata.isSymbolicLink() || + metadata.size < 2 || + metadata.size > maximumBytes + ) { + throw new Error('文件缺失、类型无效或大小超限'); + } + const noFollowFlag = + process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0); + const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag); + try { + return JSON.parse(await file.readFile('utf8')); + } finally { + await file.close(); + } +} + +async function readSafeJsonLines(projectPath, relativePath, maximumBytes) { + const targetPath = resolveProjectEvidencePath(projectPath, relativePath); + const metadata = await lstat(targetPath); + if ( + !metadata.isFile() || + metadata.isSymbolicLink() || + metadata.size < 2 || + metadata.size > maximumBytes + ) { + throw new Error('JSONL 文件缺失、类型无效或大小超限'); + } + const noFollowFlag = + process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0); + const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag); + try { + const content = await file.readFile('utf8'); + if (!content.endsWith('\n')) throw new Error('JSONL 尾记录不完整'); + return content + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } finally { + await file.close(); + } +} + +async function inspectReadyTaskExactlyOnce( + projectPath, + parentRunId, + agentDbRecords, +) { + const issues = []; + for (const taskId of requiredSwarmManifestTaskIds) { + const journalPath = `.agent/runtime/tasks/${taskId}.jsonl`; + try { + const journal = await readSafeJsonLines( + projectPath, + journalPath, + 8 * 1024 * 1024, + ); + const currentRunRecords = journal.filter( + (record) => + record?.agentId === taskId && + record?.taskId === taskId && + record?.source === 'agent-ready-task-scheduler' && + record?.parentAgentId === 'project-supervisor' && + record?.parentRunId === parentRunId, + ); + const runIds = [ + ...new Set(currentRunRecords.map((record) => record?.runId)), + ].filter((runId) => typeof runId === 'string' && runId.length > 0); + if (runIds.length !== 1) throw new Error('logical-run-count'); + const runId = runIds[0]; + const latest = currentRunRecords + .filter((record) => record?.runId === runId) + .at(-1); + if ( + latest?.status !== 'completed' || + latest?.phase !== 'completed' || + latest?.runProfile !== 'autonomous-game-build' + ) { + throw new Error('logical-run-terminal'); + } + const count = (recordType) => + agentDbRecords.filter( + (record) => + record?.recordType === recordType && + record?.agentId === taskId && + record?.taskId === taskId && + record?.runId === runId && + record?.source === 'agent-ready-task-scheduler', + ).length; + const projections = agentDbRecords.filter( + (record) => + record?.recordType === + 'agent.runtime.autonomous_ready_task.manifest_projected' && + record?.agentId === taskId && + record?.taskId === taskId && + record?.runId === runId && + record?.source === 'agent-ready-task-scheduler' && + record?.parentAgentId === 'project-supervisor' && + record?.parentRunId === parentRunId && + record?.terminalPhase === 'completed' && + record?.manifestStatus === 'completed', + ); + if ( + count('agent.runtime.background_task') !== 1 || + count('agent.runtime.background_task.completed') !== 1 || + count('agent.runtime.background_task.failed') !== 0 || + count('agent.runtime.background_task.cancelled') !== 0 || + projections.length !== 1 + ) { + throw new Error('lifecycle-count'); + } + } catch { + issues.push({ + path: journalPath, + reason: `正式任务 ${taskId} 未在当前父 Run 中恰好启动并完成一次`, + }); + } + } + return issues; +} + +async function inspectSwarmRuntimeAcceptance(projectPath, parentRunId = null) { + const issues = []; + let manifest; + try { + manifest = await readSafeJson( + projectPath, + '.agent/manifest.json', + 2 * 1024 * 1024, + ); + } catch { + issues.push({ + path: '.agent/manifest.json', + reason: '无法读取正式任务图', + }); + } + if (manifest) { + const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : []; + const taskIds = tasks.map((task) => task?.id); + const expectedIds = [...requiredSwarmManifestTaskIds].sort(); + const actualIds = [...taskIds].sort(); + if ( + tasks.length !== requiredSwarmManifestTaskIds.length || + new Set(taskIds).size !== taskIds.length || + JSON.stringify(actualIds) !== JSON.stringify(expectedIds) || + tasks.some((task) => task?.status !== 'completed') + ) { + issues.push({ + path: '.agent/manifest.json', + reason: '固定 16 个正式任务未全部且仅完成一次', + }); + } + } + + let revision; + try { + const revisionRecord = await readSafeJson( + projectPath, + '.agent/runtime/project-revision.json', + 64 * 1024, + ); + revision = revisionRecord?.revision; + if (!Number.isSafeInteger(revision) || revision < 1) throw new Error(); + } catch { + issues.push({ + path: '.agent/runtime/project-revision.json', + reason: '当前项目 revision 无效', + }); + } + + let records = []; + let recordsValid = true; + try { + const databasePath = path.join(projectPath, '.agent', 'agent.db'); + const metadata = await lstat(databasePath); + if ( + !metadata.isFile() || + metadata.isSymbolicLink() || + metadata.size < 2 || + metadata.size > 64 * 1024 * 1024 + ) { + throw new Error(); + } + records = (await readFile(databasePath, 'utf8')) + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch { + recordsValid = false; + issues.push({ path: '.agent/agent.db', reason: 'Runtime 证据库无效' }); + } + if (!recordsValid) return issues; + if (parentRunId) { + issues.push( + ...(await inspectReadyTaskExactlyOnce(projectPath, parentRunId, records)), + ); + } + const staticSmoke = records.some( + (record) => + record?.recordType === 'agent.runtime.command.run_limited' && + record?.commandId === 'game.static_smoke' && + record?.status === 'completed' && + record?.revision === revision, + ); + if (!staticSmoke) { + issues.push({ + path: '.agent/agent.db', + reason: '缺少当前 revision 的静态检查通过凭证', + }); + } + const browserEvidence = records + .filter( + (record) => + record?.recordType === 'agent.runtime.preview.validation' && + record?.passed === true && + record?.playtestPassed === true && + record?.revision === revision, + ) + .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]; + if (!browserEvidence) { + issues.push({ + path: '.agent/agent.db', + reason: '缺少当前 revision 的桌面与移动试玩通过凭证', + }); + return issues; + } + const screenshots = Array.isArray(browserEvidence.screenshots) + ? browserEvidence.screenshots + : []; + for (const viewport of ['desktop', 'mobile']) { + const screenshot = screenshots.find((item) => + String(item).endsWith(`/${viewport}.png`), + ); + let screenshotValid = false; + try { + if (screenshot) { + await validatePngFile( + resolveProjectEvidencePath(projectPath, screenshot), + ); + screenshotValid = true; + } + } catch { + screenshotValid = false; + } + if (!screenshotValid) { + issues.push({ + path: '.agent/agent.db', + reason: `缺少 ${viewport} 试玩截图`, + }); + } + } + try { + const report = await readSafeJson( + projectPath, + browserEvidence.reportPath, + 2 * 1024 * 1024, + ); + const viewportResults = Array.isArray(report?.viewportResults) + ? report.viewportResults + : []; + if ( + report?.passed !== true || + report?.playtest?.passed !== true || + !['desktop', 'mobile'].every((viewport) => + viewportResults.some( + (result) => result?.viewport === viewport && result?.passed === true, + ), + ) + ) { + throw new Error(); + } + } catch { + issues.push({ + path: String( + browserEvidence.reportPath ?? '.agent/runtime/browser-validations', + ), + reason: '双视口试玩报告无效或已过期', + }); + } + return issues; +} + +export async function inspectSwarmProjectArtifacts( + projectPath, + { requireEditorImages = false, parentRunId = null } = {}, +) { + const specs = requireEditorImages + ? [...requiredFormalArtifactSpecs, ...editorImageArtifactSpecs] + : requiredFormalArtifactSpecs; + const issues = ( + await Promise.all( + specs.map((spec) => inspectFormalArtifact(projectPath, spec)), + ) + ).filter(Boolean); + issues.push( + ...(await inspectSwarmRuntimeAcceptance(projectPath, parentRunId)), + ); + return { + valid: issues.length === 0, + requireEditorImages, + invalidPaths: issues.map((issue) => issue.path), + issues, + }; +} + +export async function validateSwarmProjectArtifacts(projectPath, options) { + const inspection = await inspectSwarmProjectArtifacts(projectPath, options); + if (!inspection.valid) { + throw new Error( + `Agent Swarm 已退出,但最小正式产物检查失败:\n${inspection.issues + .map((issue) => `- ${issue.path}:${issue.reason}`) + .join('\n')}`, + ); + } + return inspection; +} + +export async function hasConfiguredEditorApiKey(configDir) { + let configured = false; + for (const fileName of [configFileName, localConfigFileName]) { + const configPath = path.join(configDir, fileName); + const metadata = await lstat(configPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) continue; + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`运行配置必须是无符号链接普通文件:${fileName}`); + } + let config; + try { + config = JSON.parse(await readFile(configPath, 'utf8')); + } catch (error) { + throw new Error(`解析运行配置失败:${fileName}:${error.message}`); + } + if ( + config?.editorApi && + Object.prototype.hasOwnProperty.call(config.editorApi, 'apiKey') && + typeof config.editorApi.apiKey === 'string' + ) { + configured = config.editorApi.apiKey.trim().length > 0; + } + } + return configured; +} + export async function openPreviewUrl(url, platform = process.platform) { const validated = validatePreviewUrl(url); const command = @@ -589,51 +1758,78 @@ async function runPreview( ); } } finally { - if (child.exitCode === null && child.signalCode === null && !child.killed) { - try { - child.kill('SIGINT'); - } catch { - child.kill(); - } - await exitPromise.catch(() => {}); + if (!closedChildren.has(child)) { + await terminateChildTreeAndWait( + child, + exitPromise, + 'SIGINT', + '持续预览进程', + ); } setActiveChild(null); } } export async function runSwarmTestChat(options) { - const sourceConfigDir = await discoverRuntimeConfigDir(options.configDir); + let sourceConfigDir = null; let runtimeConfig = null; let project = null; let activeChild = null; let receivedSignal = null; + let timedOut = false; let phase = 'setup'; let runnerMayHaveStarted = false; + let turnReport = null; let primaryError = null; + let forceTerminationHandle = null; const setActiveChild = (child) => { activeChild = child; }; const stopRequested = () => receivedSignal !== null; const handleSignal = (signal) => { + const repeatedSignal = receivedSignal !== null; receivedSignal ??= signal; - if ( - !activeChild || - activeChild.exitCode !== null || - activeChild.signalCode !== null - ) { - return; - } - try { - activeChild.kill(signal); - } catch { - activeChild.kill(); + if (!activeChild) return; + void terminateChildTree(activeChild, signal, repeatedSignal).catch( + () => {}, + ); + if (repeatedSignal) return; + forceTerminationHandle = setTimeout(() => { + void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {}); + }, childTerminationGraceMs); + forceTerminationHandle.unref(); + }; + const clearForceTermination = () => { + if (forceTerminationHandle) { + clearTimeout(forceTerminationHandle); + forceTerminationHandle = null; } }; + const timeoutMs = resolveSwarmTestTimeoutMs(options); + const timeoutDeadline = timeoutMs === null ? null : Date.now() + timeoutMs; + const timeoutHandle = + timeoutMs === null + ? null + : setTimeout(() => { + timedOut = true; + console.error( + `agc:test:chat 已达到 ${options.timeoutMinutes ?? 50} 分钟执行期限,正在安全收束。`, + ); + handleSignal('SIGTERM'); + }, timeoutMs); + timeoutHandle?.unref(); process.on('SIGINT', handleSignal); process.on('SIGTERM', handleSignal); try { session: { + try { + sourceConfigDir = await discoverRuntimeConfigDir(options.configDir); + } catch (error) { + if (!(await askToConfigureMissingRuntime())) throw error; + await runMissingConfigWizard(setActiveChild, options.configDir); + sourceConfigDir = await discoverRuntimeConfigDir(options.configDir); + } runtimeConfig = await prepareSwarmTestRuntimeConfig(sourceConfigDir); if (receivedSignal) break session; project = await prepareSwarmTestProject(options.projectDir); @@ -662,35 +1858,66 @@ export async function runSwarmTestChat(options) { } console.log('LLM 配置已就绪。'); console.log( - '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', + options.task + ? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n' + : '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', ); phase = 'chat'; runnerMayHaveStarted = true; - const chat = await runInteractiveCargo( - [ - '--config-dir', - runtimeConfig.path, - '--swarm-chat', - '--init', - '--autonomous-game-build', - project.path, - ], - setActiveChild, - ); + const chatArguments = [ + '--config-dir', + runtimeConfig.path, + '--swarm-chat', + '--init', + '--autonomous-game-build', + project.path, + ]; + let chat; + try { + chat = options.task + ? await runTaskCargo( + chatArguments, + options.task, + setActiveChild, + timeoutDeadline === null + ? null + : Math.max(1, timeoutDeadline - Date.now()), + ) + : await runInteractiveCargo(chatArguments, setActiveChild); + } catch (error) { + if (error?.code === 'AGC_CHILD_TIMEOUT') timedOut = true; + throw error; + } if (receivedSignal) break session; if (chat.code !== 0 || chat.signal) { throw new Error( `Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`, ); } - phase = 'preview'; - if (!(await hasGeneratedGameEntry(project.path))) { - throw new Error( - 'Agent Swarm 已退出,但 game/index.html 仍是初始化占位页', + if (options.task) { + turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput); + } + phase = 'artifact-validation'; + const requireEditorImages = await hasConfiguredEditorApiKey( + runtimeConfig.path, + ); + await validateSwarmProjectArtifacts(project.path, { + requireEditorImages, + parentRunId: turnReport?.parentRunId ?? null, + }); + + if (!shouldStartPersistentPreview(options)) { + phase = 'complete'; + console.log( + `\n真实 Swarm 测试通过:正式产物${ + requireEditorImages ? '及画布图片' : '' + }已验收,Runtime 已完成静态检查和双视口试玩。`, ); + break session; } + phase = 'preview'; console.log('\nAgent Swarm 已收束,正在启动试玩...'); await runPreview( project.path, @@ -703,8 +1930,8 @@ export async function runSwarmTestChat(options) { } catch (error) { primaryError = error; } finally { - process.off('SIGINT', handleSignal); - process.off('SIGTERM', handleSignal); + if (timeoutHandle) clearTimeout(timeoutHandle); + clearForceTermination(); let cleanupError = null; let runnerStopped = true; if (runtimeConfig && runnerMayHaveStarted) { @@ -725,7 +1952,9 @@ export async function runSwarmTestChat(options) { } const preserveFailedRun = project?.owned && - (phase === 'chat' || (phase === 'preview' && !receivedSignal)); + (phase === 'chat' || + phase === 'artifact-validation' || + (phase === 'preview' && !receivedSignal)); const preserveForRunner = project?.owned && !runnerStopped; if ( project?.owned && @@ -764,6 +1993,24 @@ export async function runSwarmTestChat(options) { primaryError = cleanupError; } } + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + } + if (timedOut) { + if (primaryError) { + console.warn(`超时收束附带错误:${primaryError.message}`); + } + primaryError = Object.assign(new Error('真实 Swarm 测试超过内部执行期限'), { + exitCode: 124, + }); + } else if (receivedSignal) { + if (primaryError) { + console.warn(`信号收束附带错误:${primaryError.message}`); + } + primaryError = Object.assign( + new Error(`真实 Swarm 测试收到 ${receivedSignal}`), + { exitCode: receivedSignal === 'SIGINT' ? 130 : 143 }, + ); } if (primaryError) throw primaryError; } @@ -783,6 +2030,6 @@ const entryPath = process.argv[1] if (entryPath === import.meta.url) { main().catch((error) => { console.error(`agc:test:chat 失败:${error.message}`); - process.exitCode = 1; + process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1; }); } diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 576e2e324..e79274176 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1,4 +1,22 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + askHidden, + assertSafeGameCreatorConfigDestination, + buildGameCreatorWizardConfig, + readGameCreatorWizardConfigState, + writeGameCreatorConfigAtomically, + writeGameCreatorWizardConfig, +} from './game-creator-config-wizard.mjs'; +import { + appIdentifier, + defaultRealSwarmTestTask, +} from './agent-swarm-test-chat.mjs'; const packageConfig = JSON.parse( fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'), @@ -280,6 +298,522 @@ function assertCommandNamesSubset(label, leftNames, rightNames) { } } +function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) { + return { code, signal, stdout, stderr }; +} + +class HiddenInputFixture extends EventEmitter { + constructor({ failRawRestore = false } = {}) { + super(); + this.isTTY = true; + this.isRaw = false; + this.paused = true; + this.failRawRestore = failRawRestore; + this.rawModeChanges = []; + } + + isPaused() { + return this.paused; + } + + setRawMode(enabled) { + this.rawModeChanges.push(enabled); + this.isRaw = enabled; + if (!enabled && this.failRawRestore) { + throw new Error('fixture raw restore failure'); + } + return this; + } + + resume() { + this.paused = false; + return this; + } + + pause() { + this.paused = true; + return this; + } +} + +function hiddenOutputFixture() { + const writes = []; + return { + writes, + write(value) { + writes.push(value); + return true; + }, + }; +} + +function assertHiddenInputRestored(input) { + assert.equal(input.listenerCount('data'), 0); + assert.equal(input.listenerCount('end'), 0); + assert.equal(input.listenerCount('error'), 0); + assert.equal(input.paused, true); +} + +async function runHiddenInputRegressionChecks() { + const endedInput = new HiddenInputFixture(); + const endedPromise = askHidden('fixture', { + input: endedInput, + output: hiddenOutputFixture(), + }); + endedInput.emit('end'); + await assert.rejects(endedPromise, /隐藏输入在完成前已结束/u); + assert.deepEqual(endedInput.rawModeChanges, [true, false]); + assertHiddenInputRestored(endedInput); + + const erroredInput = new HiddenInputFixture(); + const erroredPromise = askHidden('fixture', { + input: erroredInput, + output: hiddenOutputFixture(), + }); + erroredInput.emit('error', new Error('fixture stdin error')); + await assert.rejects(erroredPromise, /fixture stdin error/u); + assert.deepEqual(erroredInput.rawModeChanges, [true, false]); + assertHiddenInputRestored(erroredInput); + + const restoreFailedInput = new HiddenInputFixture({ failRawRestore: true }); + const restoreFailedPromise = askHidden('fixture', { + input: restoreFailedInput, + output: hiddenOutputFixture(), + }); + restoreFailedInput.emit('data', Buffer.from('secret\n')); + await assert.rejects(restoreFailedPromise, /恢复终端 raw mode 失败/u); + assert.deepEqual(restoreFailedInput.rawModeChanges, [true, false]); + assertHiddenInputRestored(restoreFailedInput); + + const signaledInput = new HiddenInputFixture(); + const signalSource = new EventEmitter(); + const forwardedSignals = []; + const signaledPromise = askHidden('fixture', { + input: signaledInput, + output: hiddenOutputFixture(), + signalSource, + terminateForSignal: (signal) => forwardedSignals.push(signal), + platform: 'win32', + }); + signalSource.emit('SIGBREAK'); + await assert.rejects(signaledPromise, /SIGBREAK/u); + assert.deepEqual(signaledInput.rawModeChanges, [true, false]); + assertHiddenInputRestored(signaledInput); + assert.deepEqual(forwardedSignals, ['SIGBREAK']); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) { + assert.equal(signalSource.listenerCount(signal), 0); + } +} + +async function runConfigWizardRegressionChecks() { + const testRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-agc-config-check-'), + ); + try { + const gitRoot = path.join(testRoot, 'tracked-repository'); + const trackedConfigDir = path.join(gitRoot, 'runtime-config'); + fs.mkdirSync(trackedConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(trackedConfigDir, 'game-creator.config.json'), + '{}\n', + { mode: 0o600 }, + ); + execFileSync('git', ['init', '--quiet', gitRoot], { stdio: 'ignore' }); + execFileSync( + 'git', + ['-C', gitRoot, 'add', 'runtime-config/game-creator.config.json'], + { stdio: 'ignore' }, + ); + await assert.rejects( + assertSafeGameCreatorConfigDestination(trackedConfigDir), + /Git 已跟踪/u, + ); + + const untrackedConfigDir = path.join(gitRoot, 'untracked-runtime-config'); + await assert.rejects( + assertSafeGameCreatorConfigDestination(untrackedConfigDir), + /Git 仓库之外/u, + ); + const outsideConfigDir = path.join(testRoot, 'outside-appdata'); + assert.equal( + await assertSafeGameCreatorConfigDestination(outsideConfigDir), + outsideConfigDir, + ); + await assert.rejects( + assertSafeGameCreatorConfigDestination(outsideConfigDir, { + requireDedicatedLeaf: true, + }), + new RegExp(appIdentifier.replaceAll('.', '\\.')), + ); + const dedicatedConfigDir = path.join(testRoot, appIdentifier); + assert.equal( + await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, { + requireDedicatedLeaf: true, + }), + dedicatedConfigDir, + ); + + const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git'); + assert.equal( + await assertSafeGameCreatorConfigDestination(injectedNonGitConfigDir, { + runGit: async () => + gitCheckResult({ + code: 128, + stderr: + 'fatal: not a git repository (or any of the parent directories): .git\n', + }), + }), + injectedNonGitConfigDir, + ); + await assert.rejects( + assertSafeGameCreatorConfigDestination( + path.join(testRoot, 'missing-git-command'), + { + runGit: async () => { + const error = new Error('spawn git ENOENT'); + error.code = 'ENOENT'; + throw error; + }, + }, + ), + /Git 命令不可用或启动失败/u, + ); + await assert.rejects( + assertSafeGameCreatorConfigDestination( + path.join(testRoot, 'abnormal-rev-parse'), + { + runGit: async () => + gitCheckResult({ + code: 129, + stderr: 'fixture rev-parse failure', + }), + }, + ), + /Git 检查异常.*fixture rev-parse failure/u, + ); + + const abnormalTrackedGitRoot = path.join( + testRoot, + 'abnormal-tracked-repository', + ); + const abnormalTrackedConfigDir = path.join( + abnormalTrackedGitRoot, + 'runtime-config', + ); + fs.mkdirSync(abnormalTrackedConfigDir, { recursive: true }); + let gitCheckCount = 0; + await assert.rejects( + assertSafeGameCreatorConfigDestination(abnormalTrackedConfigDir, { + runGit: async () => { + gitCheckCount += 1; + if (gitCheckCount === 1) { + return gitCheckResult({ stdout: `${abnormalTrackedGitRoot}\n` }); + } + return gitCheckResult({ + code: 2, + stderr: 'fixture ls-files failure', + }); + }, + }), + /配置文件跟踪状态.*fixture ls-files failure/u, + ); + assert.equal(gitCheckCount, 2); + + const overlayConfigDir = path.join(testRoot, 'overlay-appdata'); + fs.mkdirSync(overlayConfigDir, { recursive: true, mode: 0o700 }); + const primaryConfigPath = path.join( + overlayConfigDir, + 'game-creator.config.json', + ); + const localConfigPath = path.join( + overlayConfigDir, + 'game-creator.config.local.json', + ); + const primaryConfig = { + llm: { + apiKey: 'fixture-primary-key', + baseUrl: 'https://primary.example.test/v1', + model: 'primary-model', + apiKind: 'openai_chat', + stream: false, + requestTimeoutMs: 12345, + }, + editorApi: { + apiKey: 'fixture-editor-key', + baseUrl: 'http://127.0.0.1:8082', + }, + mcpServers: { + primary: { command: 'fixture-primary-command' }, + }, + }; + const localConfig = { + llm: { + apiKey: 'fixture-old-overlay-key', + model: 'old-overlay-model', + stream: true, + }, + agentLlm: { + planner: { model: 'planner-overlay-model' }, + }, + mcpServers: { + local: { command: 'fixture-local-command' }, + }, + }; + fs.writeFileSync( + primaryConfigPath, + `${JSON.stringify(primaryConfig, null, 2)}\n`, + { mode: 0o644 }, + ); + fs.writeFileSync( + localConfigPath, + `${JSON.stringify(localConfig, null, 2)}\n`, + { mode: 0o644 }, + ); + const overlayState = + await readGameCreatorWizardConfigState(overlayConfigDir); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(primaryConfigPath).mode & 0o777, 0o600); + assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600); + } + assert.equal(overlayState.configPath, primaryConfigPath); + assert.equal(overlayState.localConfigPath, localConfigPath); + assert.equal(overlayState.effectiveConfig.llm.model, 'old-overlay-model'); + assert.equal(overlayState.effectiveConfig.llm.stream, true); + assert.equal(overlayState.effectiveConfig.llm.requestTimeoutMs, 12345); + assert.deepEqual(Object.keys(overlayState.effectiveConfig.mcpServers), [ + 'primary', + 'local', + ]); + assert.deepEqual( + overlayState.writeConfig.editorApi, + primaryConfig.editorApi, + ); + + const updatedPrimary = buildGameCreatorWizardConfig( + overlayState.writeConfig, + { + apiKey: 'fixture-new-key', + baseUrl: 'https://new.example.test/v1/', + model: 'new-model', + apiKind: 'openai_responses', + }, + ); + await writeGameCreatorWizardConfig(overlayState, updatedPrimary); + const reloadedState = + await readGameCreatorWizardConfigState(overlayConfigDir); + assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key'); + assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model'); + assert.equal( + reloadedState.effectiveConfig.llm.baseUrl, + 'https://new.example.test/v1', + ); + assert.equal(reloadedState.effectiveConfig.llm.requestTimeoutMs, 12345); + assert.equal(reloadedState.effectiveConfig.llm.stream, true); + assert.equal( + JSON.parse(fs.readFileSync(primaryConfigPath, 'utf8')).llm.apiKey, + 'fixture-new-key', + ); + const sanitizedLocalConfig = JSON.parse( + fs.readFileSync(localConfigPath, 'utf8'), + ); + assert.equal(sanitizedLocalConfig.llm, undefined); + assert.deepEqual(sanitizedLocalConfig.agentLlm, localConfig.agentLlm); + assert.deepEqual(sanitizedLocalConfig.mcpServers, localConfig.mcpServers); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600); + } + + const unchangedLocalConfigDir = path.join( + testRoot, + 'unchanged-local-appdata', + ); + fs.mkdirSync(unchangedLocalConfigDir, { recursive: true, mode: 0o700 }); + const unchangedPrimaryConfigPath = path.join( + unchangedLocalConfigDir, + 'game-creator.config.json', + ); + const unchangedLocalConfigPath = path.join( + unchangedLocalConfigDir, + 'game-creator.config.local.json', + ); + const unchangedLocalSource = + '{\n "editorApi": { "baseUrl": "http://127.0.0.1:8082" }\n}\n'; + fs.writeFileSync(unchangedPrimaryConfigPath, '{}\n', { mode: 0o600 }); + fs.writeFileSync(unchangedLocalConfigPath, unchangedLocalSource, { + mode: 0o644, + }); + const unchangedLocalState = await readGameCreatorWizardConfigState( + unchangedLocalConfigDir, + ); + await writeGameCreatorWizardConfig( + unchangedLocalState, + buildGameCreatorWizardConfig(unchangedLocalState.writeConfig, { + apiKey: 'fixture-private-local-key', + baseUrl: 'https://private.example.test/v1', + model: 'private-model', + apiKind: 'openai_responses', + }), + ); + assert.equal( + fs.readFileSync(unchangedLocalConfigPath, 'utf8'), + unchangedLocalSource, + ); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(unchangedLocalConfigPath).mode & 0o777, 0o600); + } + + const invalidLocalConfigDir = path.join(testRoot, 'invalid-local-appdata'); + fs.mkdirSync(invalidLocalConfigDir, { recursive: true, mode: 0o700 }); + const invalidPrimaryConfigPath = path.join( + invalidLocalConfigDir, + 'game-creator.config.json', + ); + const invalidLocalConfigPath = path.join( + invalidLocalConfigDir, + 'game-creator.config.local.json', + ); + fs.writeFileSync(invalidPrimaryConfigPath, '{}\n', { mode: 0o600 }); + fs.writeFileSync(invalidLocalConfigPath, '{ invalid json\n', { + mode: 0o644, + }); + await assert.rejects( + writeGameCreatorWizardConfig( + { + configPath: invalidPrimaryConfigPath, + localConfigPath: invalidLocalConfigPath, + }, + { llm: { apiKey: 'fixture-invalid-local-key' } }, + ), + /读取客户端配置失败/u, + ); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(invalidLocalConfigPath).mode & 0o777, 0o600); + } + + const windowsLocalConfigDir = path.join(testRoot, 'windows-local-appdata'); + fs.mkdirSync(windowsLocalConfigDir, { recursive: true }); + const windowsLocalPrimaryPath = path.join( + windowsLocalConfigDir, + 'game-creator.config.json', + ); + const windowsLocalConfigPath = path.join( + windowsLocalConfigDir, + 'game-creator.config.local.json', + ); + fs.writeFileSync(windowsLocalPrimaryPath, '{}\n'); + fs.writeFileSync(windowsLocalConfigPath, unchangedLocalSource); + const windowsReadAclPaths = []; + const windowsLocalState = await readGameCreatorWizardConfigState( + windowsLocalConfigDir, + { + platform: 'win32', + secureWindowsPath: async (targetPath, { isDirectory }) => { + windowsReadAclPaths.push({ targetPath, isDirectory }); + }, + }, + ); + assert.deepEqual(windowsReadAclPaths, [ + { targetPath: windowsLocalConfigDir, isDirectory: true }, + { targetPath: windowsLocalPrimaryPath, isDirectory: false }, + { targetPath: windowsLocalConfigPath, isDirectory: false }, + ]); + const windowsLocalAclPaths = []; + await writeGameCreatorWizardConfig( + windowsLocalState, + buildGameCreatorWizardConfig(windowsLocalState.writeConfig, { + apiKey: 'fixture-windows-local-key', + baseUrl: 'https://windows.example.test/v1', + model: 'windows-model', + apiKind: 'openai_responses', + }), + { + platform: 'win32', + secureWindowsPath: async (targetPath, { isDirectory }) => { + if (!isDirectory) windowsLocalAclPaths.push(targetPath); + }, + }, + ); + assert.equal( + fs.readFileSync(windowsLocalConfigPath, 'utf8'), + unchangedLocalSource, + ); + assert.equal(windowsLocalAclPaths.includes(windowsLocalConfigPath), true); + + const guiSavedConfig = JSON.parse( + fs.readFileSync(primaryConfigPath, 'utf8'), + ); + guiSavedConfig.llm = { + ...guiSavedConfig.llm, + apiKey: 'fixture-gui-key', + model: 'gui-model', + }; + await writeGameCreatorConfigAtomically(primaryConfigPath, guiSavedConfig); + const afterGuiSave = + await readGameCreatorWizardConfigState(overlayConfigDir); + assert.equal(afterGuiSave.effectiveConfig.llm.apiKey, 'fixture-gui-key'); + assert.equal(afterGuiSave.effectiveConfig.llm.model, 'gui-model'); + + const windowsConfigDir = path.join(testRoot, 'windows-appdata'); + const windowsConfigPath = path.join( + windowsConfigDir, + 'game-creator.config.json', + ); + const aclEvents = []; + await writeGameCreatorConfigAtomically( + windowsConfigPath, + { llm: { apiKey: 'fixture-windows-key' } }, + { + platform: 'win32', + secureWindowsPath: async (targetPath, { isDirectory }) => { + const metadata = fs.lstatSync(targetPath); + const temporaryFile = + !isDirectory && path.basename(targetPath).startsWith('.'); + if (temporaryFile) { + assert.equal(metadata.size, 0); + assert.equal(fs.readFileSync(targetPath, 'utf8'), ''); + } + aclEvents.push({ isDirectory, temporaryFile }); + }, + }, + ); + assert.deepEqual(aclEvents, [ + { isDirectory: true, temporaryFile: false }, + { isDirectory: false, temporaryFile: true }, + { isDirectory: false, temporaryFile: false }, + ]); + assert.equal( + JSON.parse(fs.readFileSync(windowsConfigPath, 'utf8')).llm.apiKey, + 'fixture-windows-key', + ); + + const failedWindowsConfigDir = path.join( + testRoot, + 'failed-windows-appdata', + ); + const failedWindowsConfigPath = path.join( + failedWindowsConfigDir, + 'game-creator.config.json', + ); + await assert.rejects( + writeGameCreatorConfigAtomically( + failedWindowsConfigPath, + { llm: { apiKey: 'fixture-must-not-reach-disk' } }, + { + platform: 'win32', + secureWindowsPath: async (_targetPath, { isDirectory }) => { + if (!isDirectory) throw new Error('fixture DACL failure'); + }, + }, + ), + /fixture DACL failure/u, + ); + assert.equal(fs.existsSync(failedWindowsConfigPath), false); + assert.deepEqual(fs.readdirSync(failedWindowsConfigDir), []); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +} + assertNoOpenAiApiKeys([ new URL('../src/', import.meta.url), new URL('../scripts/', import.meta.url), @@ -405,15 +939,33 @@ if ( ); } +if ( + packageConfig.scripts?.config !== + 'node scripts/game-creator-config-wizard.mjs' +) { + throw new Error( + 'AI game creator shell config must use the AppData configuration wizard', + ); +} + if ( packageConfig.scripts?.['test:chat'] !== - 'node scripts/agent-swarm-test-chat.mjs' + `node scripts/agent-swarm-test-chat.mjs --task ${JSON.stringify(defaultRealSwarmTestTask)} --no-open` ) { throw new Error( 'AI game creator shell test:chat must use the one-click Swarm test entry', ); } +if ( + packageConfig.scripts?.['test:chat:manual'] !== + 'node scripts/agent-swarm-test-chat.mjs' +) { + throw new Error( + 'AI game creator shell test:chat:manual must keep the interactive Swarm test entry', + ); +} + if ( rootPackageConfig.scripts?.['agc:test'] !== 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --' @@ -421,6 +973,15 @@ if ( throw new Error('agc:test must delegate to the deterministic playable E2E'); } +if ( + rootPackageConfig.scripts?.['agc:config'] !== + 'npm --prefix apps/ai-game-creator-shell run config --' +) { + throw new Error( + 'agc:config must delegate to the AppData configuration wizard', + ); +} + if ( rootPackageConfig.scripts?.['agc:test:chat'] !== 'npm --prefix apps/ai-game-creator-shell run test:chat --' @@ -430,6 +991,15 @@ if ( ); } +if ( + rootPackageConfig.scripts?.['agc:test:chat:manual'] !== + 'npm --prefix apps/ai-game-creator-shell run test:chat:manual --' +) { + throw new Error( + 'agc:test:chat:manual must delegate to the interactive Swarm test entry', + ); +} + for (const requiredSource of [ "export const appIdentifier = 'world.genarrative.ai-game-creator'", "'--swarm-chat'", @@ -636,6 +1206,22 @@ const runCliWithConfigSource = fs.readFileSync( 'utf8', ); +if (!runCliWithConfigSource.includes('resolveGameCreatorAppConfigDir')) { + throw new Error( + 'AI game creator shell CLI wrapper must resolve the GUI AppData directory', + ); +} + +if ( + !runCliWithConfigSource.includes( + "'--config-dir', resolveGameCreatorAppConfigDir()", + ) +) { + throw new Error( + 'AI game creator shell CLI wrapper must pass the GUI AppData directory to native commands', + ); +} + for (const snippet of [ 'const port = 3080', "response.body.includes('AI 游戏创作')", @@ -651,6 +1237,29 @@ for (const snippet of [ } } +const configWizardSource = fs.readFileSync( + new URL('./game-creator-config-wizard.mjs', import.meta.url), + 'utf8', +); +for (const snippet of [ + 'assertSafeGameCreatorConfigDestination', + 'readGameCreatorWizardConfigState', + '$security.SetAccessRuleProtection($true, $false)', + '$rules.Count -ne 1', + '[System.Security.AccessControl.FileSystemRights]::FullControl', + 'await secureWindowsPath(temporaryPath, { isDirectory: false })', + 'await temporaryFile.writeFile', +]) { + if (!configWizardSource.includes(snippet)) { + throw new Error( + `AI game creator config wizard guardrail drifted: ${snippet}`, + ); + } +} + +await runConfigWizardRegressionChecks(); +await runHiddenInputRegressionChecks(); + for (const snippet of [ "new URL('..', import.meta.url)", "'--manifest-path'", 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 907a1e964..8ed22af4a 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 @@ -1,4 +1,5 @@ import http from 'node:http'; +import { deflateSync } from 'node:zlib'; const LOOPBACK_HOST = '127.0.0.1'; const DEFAULT_FALLBACK_PORTS = Object.freeze( @@ -6,26 +7,123 @@ const DEFAULT_FALLBACK_PORTS = Object.freeze( ); const CHAT_COMPLETIONS_PATH = '/v1/chat/completions'; const MAX_REQUEST_BYTES = 16 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const PNG_CRC_TABLE = Object.freeze( + Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + return crc >>> 0; + }), +); export const deterministicLaneDefenseModel = 'deterministic-lane-defense-provider-v1'; +export const deterministicManifestReadyAgentIds = Object.freeze([ + 'design-director', + 'design-foundation', + 'balance-director', + 'balance-seed', + 'art-director', + 'art-asset-plan', + 'art-polish', + 'audio-director', + 'audio-asset-plan', + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + 'preview-playtest', + 'publish-strategy', + 'publish-package', +]); +const deterministicReadOnlyReadyAgentIds = new Set([ + 'design-director', + 'balance-director', + 'art-director', + 'art-polish', + 'audio-director', + 'code-director', + 'quality-review', + 'preview-readiness', + 'preview-playtest', + 'publish-strategy', +]); +const deterministicProjectMutationTools = new Set([ + 'file.write', + 'file.patch', + 'file.delete', + 'project.patchset', + 'project.restore', + 'command.exec', + 'command.start', + 'canvas.asset_generate', +]); export const hiddenCanvasCss = '#game{display:none;'; export const visibleCanvasCss = '#game{display:block;'; +function pngCrc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = PNG_CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const typeBytes = Buffer.from(type, 'ascii'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(pngCrc32(Buffer.concat([typeBytes, data]))); + return Buffer.concat([length, typeBytes, data, crc]); +} + +function deterministicPng(width, height) { + const stride = width * 4 + 1; + const pixels = Buffer.alloc(stride * height); + for (let y = 0; y < height; y += 1) { + const row = y * stride; + pixels[row] = 0; + 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; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(pixels, { level: 6 })), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + export function deterministicLaneDefenseInitialHtml() { return ` -Garden Guardians +灵露花园
-

Garden Guardians

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

+

灵露花园

GENARRATIVE_REAL_E2E_VISIBLE

Goal: defend the garden and win every wave.

-
+
Level 1 ready
`; } +const deterministicLaneDefenseBalance = Object.freeze({ + schemaVersion: 'lane-defense-balance.v1', + startingSun: 150, + defenderCosts: { 'nectar-bloom': 50, 'thorn-sentry': 100 }, + enemyHealth: 100, + enemySpeed: 20, + waveTimingsMs: [120, 300, 560], + scorePerEnemy: 100, + levelDifficultyMultiplier: 1.18, +}); + +export function deterministicLaneDefenseCanonicalHtml() { + const upstreamContract = JSON.stringify({ + schemaVersion: 'lane-defense-upstream-contract.v1', + design: 'game/game_design.md', + balance: 'game/balance.json', + art: 'assets/manifest.art.json', + audio: 'assets/manifest.audio.json', + }); + const balance = JSON.stringify(deterministicLaneDefenseBalance); + return deterministicLaneDefenseInitialHtml() + .replace(hiddenCanvasCss, visibleCanvasCss) + .replace( + '
', + '
', + ) + .replace( + '\n", + ) + .expect("write incomplete game HTML fixture"); + let parent_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("HTML marker must block parent completion"); + assert!(parent_blocker + .detail + .as_deref() + .is_some_and(|detail| detail + .contains("game/index.html(incomplete-marker:marker=coming soon,line=1)"))); + + update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Running) + .expect("mark publish package running"); + let child_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "publish-package"); + let child_state = agent_runtime_state_from_task_record(&child_record); + fs::write( + root.join("exports/README.md"), + "# 发布说明\n\n> 1) [ ] 待发布事项\n", + ) + .expect("write incomplete child README fixture"); + let child_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &child_state) + .expect("unchecked owner checklist must block child completion"); + let child_detail = child_blocker.detail.expect("child marker blocker detail"); + assert!(child_detail + .contains("exports/README.md(incomplete-marker:marker=unchecked-checklist,line=3)")); + assert!(child_detail.contains( + "nextRequiredAction=file.read(path=exports/README.md) then file.patch(path=exports/README.md)" + )); + + fs::write( + root.join("exports/README.md"), + "# 发布说明\n\n- [x] 桌面试玩通过\n- [X] 移动试玩通过\n", + ) + .expect("write completed child README fixture"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &child_state).is_none()); +} + +#[tokio::test] +async fn autonomous_ready_scheduler_inherits_parent_profile_and_is_idempotent() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-ready-scheduler-parent"); + let ready_agent_ids = [ + "design-director", + "balance-director", + "art-director", + "audio-director", + "publish-strategy", + ]; + for agent_id in ready_agent_ids { + update_manifest_task_status_at(&root, agent_id, GameCreationAppTaskStatus::Pending) + .expect("reset autonomous ready task"); + } + let _runtime_locks = ready_agent_ids + .into_iter() + .map(|agent_id| { + try_acquire_game_creator_agent_runtime_task_lock(&root, agent_id) + .expect("acquire autonomous ready runtime lock") + .expect("autonomous ready runtime lock is free") + }) + .collect::>(); + + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + 9, + ) + .expect("schedule initial autonomous ready task"); + assert_eq!(scheduled.len(), 3); + assert_eq!(scheduled[0].state.agent_id, "design-director"); + let child_run_id = + autonomous_manifest_ready_task_run_id(&parent_state.run_id, "design-director"); + let child_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + "design-director", + &child_run_id, + ) + .expect("read autonomous ready child task") + .expect("autonomous ready child task exists"); + let child_state = agent_runtime_state_from_task_record(&child_record); + assert_eq!(child_state.source, "agent-ready-task-scheduler"); + assert_eq!( + child_state.parent_agent_id.as_deref(), + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + ); + assert_eq!( + child_state.parent_run_id.as_deref(), + Some(parent_state.run_id.as_str()) + ); + assert!(child_state.delegation_id.is_none()); + assert!(child_state + .current_task + .contains("autonomous-game-build 的只读协调任务")); + + let binding = read_game_creator_agent_runtime_run_profile_binding( + &root, + "design-director", + &child_state.run_id, + ) + .expect("read ready child profile binding") + .expect("ready child profile binding exists"); + assert_eq!( + binding.profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!( + binding.parent_binding_fingerprint.as_deref(), + Some(parent_state.run_profile_binding_fingerprint.as_str()) + ); + assert!(agent_runtime_task_requires_read_only_delivery( + "design-director", + &child_state.current_task, + )); + + let repeated = schedule_autonomous_game_build_ready_tasks_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + 3, + ) + .expect("repeat autonomous ready scheduling"); + assert!(repeated.is_empty()); +} + +#[test] +fn autonomous_ready_terminal_failures_are_projected_without_retry() { + for phase in ["failed", "budget-exhausted"] { + let parent_run_id = format!("autonomous-ready-{phase}-parent"); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", &parent_run_id); + update_manifest_task_status_at( + &root, + "design-director", + GameCreationAppTaskStatus::Running, + ) + .expect("mark failed autonomous child running"); + let record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + let child_run_id = record.run_id.clone(); + let terminal = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: phase.to_string(), + current_action: "ready task terminal failure".to_string(), + terminal_detail: Some(format!("terminal phase={phase}")), + error: Some(format!("terminal phase={phase}")), + updated_at: unix_timestamp(), + ..record + }; + append_game_creator_agent_runtime_task_record(&root, &terminal) + .expect("append failed autonomous child terminal"); + + assert!(project_autonomous_manifest_ready_task_terminal_at( + &root, + &agent_runtime_state_from_task_record(&terminal), + ) + .expect("project failed autonomous child terminal")); + assert_eq!( + read_manifest_for_project(&root) + .expect("read failed autonomous manifest") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Failed), + "phase {phase} must fail the current manifest task" + ); + assert!(schedule_autonomous_game_build_ready_tasks_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + 3, + ) + .expect("repeat scheduling after terminal failure") + .is_empty()); + + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, "design-director"), + ) + .expect("read failed autonomous child journal"); + assert_eq!( + records + .iter() + .map(|record| record.run_id.as_str()) + .collect::>(), + std::collections::BTreeSet::from([child_run_id.as_str()]), + "phase {phase} must not create a second logical child run" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-concurrent-terminal-parent"); + let child_ids = ["design-director", "balance-director"]; + let mut child_states = Vec::new(); + for child_id in child_ids { + update_manifest_task_status_at(&root, child_id, GameCreationAppTaskStatus::Running) + .expect("mark concurrent autonomous child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, child_id); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + child_states.push(state); + } + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(child_states.len())); + let projections = child_states + .into_iter() + .map(|state| { + let root = root.clone(); + let barrier = barrier.clone(); + tokio::task::spawn_blocking(move || { + barrier.wait(); + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + }) + }) + .collect::>(); + for projection in projections { + assert!(projection + .await + .expect("join concurrent terminal projection") + .expect("project concurrent terminal state")); + } + + let manifest = read_manifest_for_project(&root).expect("read projected autonomous manifest"); + for child_id in child_ids { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == child_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed), + "concurrent projection lost task status for {child_id}" + ); + } +} + +#[test] +fn autonomous_scheduler_child_terminal_skips_static_delegate_delivery_protocol() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-scheduler-delivery-parent"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark scheduler child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + + publish_game_creator_agent_delegate_result_for_state(&root, &state, None); + + let manifest = read_manifest_for_project(&root).expect("read scheduler child manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB"); + assert!(!agent_db.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .and_then(|record| { + record + .get("recordType") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .as_deref() + == Some("agent.runtime.agent.delegate.result_failed") + })); +} + +#[test] +fn autonomous_scheduler_child_terminal_projects_with_existing_project_lock() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-scheduler-locked-parent"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark scheduler child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "runtime.background.complete", + ) + .expect("hold background completion project lock"); + publish_game_creator_agent_delegate_result_for_state_at_locked(&root, &state, None); + + let manifest = read_manifest_for_project(&root).expect("read scheduler child manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); +} + +#[tokio::test] +async fn autonomous_scheduler_recovers_running_without_journal_and_journal_without_running() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-reservation-recovery-parent"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("simulate legacy running reservation without child journal"); + update_manifest_task_status_at( + &root, + "balance-director", + GameCreationAppTaskStatus::Pending, + ) + .expect("prepare journal-first reservation recovery"); + let balance_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "balance-director"); + let _runtime_locks = ["design-director", "balance-director"] + .into_iter() + .map(|agent_id| { + try_acquire_game_creator_agent_runtime_task_lock(&root, agent_id) + .expect("acquire reservation recovery runtime lock") + .expect("reservation recovery runtime lock is free") + }) + .collect::>(); + + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + 3, + ) + .expect("recover both autonomous reservation crash windows"); + assert_eq!(scheduled.len(), 2); + + let manifest = read_manifest_for_project(&root).expect("read recovered manifest"); + for task_id in ["design-director", "balance-director"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Running) + ); + let expected_run_id = autonomous_manifest_ready_task_run_id(&parent_state.run_id, task_id); + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, task_id), + ) + .expect("read recovered child journal"); + assert_eq!( + records + .iter() + .filter(|record| record.run_id == expected_run_id) + .count(), + 1, + "reservation recovery must not create a duplicate child journal for {task_id}" + ); + assert!(records + .iter() + .all(|record| !record.run_id.contains("-dup-"))); + } + assert_eq!( + read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + "balance-director", + &balance_record.run_id, + ) + .expect("read journal-first recovery record") + .expect("journal-first recovery record remains durable") + .run_id, + balance_record.run_id + ); +} + +#[test] +fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() { + let (_temporary, root, old_parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-old-generation-parent"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark old-generation child running"); + let old_child_record = + queue_autonomous_manifest_child_fixture(&root, &old_parent_state, "design-director"); + + let supervisor_session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve replacement Supervisor session"); + let new_parent_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_session_id, + "重新开始一轮完整小游戏构建", + "autonomous-current-generation-parent", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue replacement autonomous root"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read replacement root manifest") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending) + ); + + let mut old_child_state = agent_runtime_state_from_task_record(&old_child_record); + old_child_state.status = "completed".to_string(); + old_child_state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &old_child_state) + .expect("ignore superseded child terminal projection") + ); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after stale projection") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending), + "old generation must not write into the replacement manifest" + ); + assert!(schedule_autonomous_game_build_ready_tasks_at( + &root, + &old_parent_state.agent_id, + &old_parent_state.run_id, + 3, + ) + .expect_err("superseded root must not schedule another wave") + .contains("已被更新的自主构建代替")); + let old_balance_run_id = + autonomous_manifest_ready_task_run_id(&old_parent_state.run_id, "balance-director"); + assert!(read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + "balance-director", + &old_balance_run_id, + ) + .expect("read stale next-wave child") + .is_none()); + + update_manifest_task_status_at( + &root, + "balance-director", + GameCreationAppTaskStatus::Running, + ) + .expect("mark current-generation child running before cancellation"); + let new_parent_state = agent_runtime_state_from_task_record(&new_parent_record); + let cancelled_parent_child = + queue_autonomous_manifest_child_fixture(&root, &new_parent_state, "balance-director"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "用户取消当前自主构建".to_string(), + terminal_detail: Some("cancelled by test".to_string()), + error: None, + updated_at: unix_timestamp(), + ..new_parent_record + }, + ) + .expect("cancel replacement autonomous root"); + let mut cancelled_parent_child_state = + agent_runtime_state_from_task_record(&cancelled_parent_child); + cancelled_parent_child_state.status = "completed".to_string(); + cancelled_parent_child_state.phase = "completed".to_string(); + assert!(project_autonomous_manifest_ready_task_terminal_at( + &root, + &cancelled_parent_child_state, + ) + .expect("ignore child terminal projection after parent cancellation")); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after cancelled-parent projection") + .tasks + .iter() + .find(|task| task.id == "balance-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Running), + "cancelled parent child must not project a terminal manifest state" + ); + assert!(schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "autonomous-current-generation-parent", + 3, + ) + .expect_err("cancelled root must not schedule") + .contains("已不再活跃")); +} + #[test] fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() { let (_temporary, root, mut state, contract) = autonomous_fixture( @@ -329,7 +1214,11 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( complete_agent_runtime_remaining_plan_steps(&mut state, "测试已完成实现与静态验证"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("baseline project must remain blocked"); - assert!(blocker.summary.contains("revision")); + assert!(blocker.summary.contains("正式产物")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); let revision = advance_game_index_revision( &root, 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 eab78d0b7..54cb35596 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 @@ -11,6 +11,7 @@ pub(crate) enum AgentBackgroundTaskOutcome { WaitingForProviderHandoff, WaitingForIsolatedJoin, WaitingForDelegateReceipts, + WaitingForManifestTasks, NeedsReconciliation, FinalizationPending, ContinueSameRun { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 9488e2a8e..db19234de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -611,7 +611,7 @@ pub(super) fn finish_game_creator_agent_background_runtime_turn_idempotently_at( )?; remove_game_creator_agent_runtime_confirmations(root, &completed.agent_id, &completed.run_id)?; provider_retry::remove_at(root, &completed.agent_id, &completed.run_id)?; - publish_game_creator_agent_delegate_result_for_state( + publish_game_creator_agent_delegate_result_for_state_at_locked( root, &completed, completed.last_response.as_deref(), @@ -2613,7 +2613,11 @@ pub(super) fn mark_game_creator_agent_runtime_cancelled_at_locked( state.updated_at = unix_timestamp(); write_game_creator_agent_runtime_state(root, state)?; } - publish_game_creator_agent_delegate_result_for_state(root, state, detail.or(Some(summary))); + publish_game_creator_agent_delegate_result_for_state_at_locked( + root, + state, + detail.or(Some(summary)), + ); Ok(()) } @@ -3392,6 +3396,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "waiting-for-process-session" | "waiting-for-isolated-join" | "waiting-for-delegate-receipts" + | "waiting-for-manifest-tasks" | "waiting-for-visual-asset" | "brief" | "paused" 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 6ebd51adc..108f7b9fd 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 @@ -32,6 +32,9 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; +#[cfg(test)] +pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; + pub(crate) use action_history::{ is_valid_agent_runtime_action_id, observe_agent_runtime_action_history, }; @@ -45,6 +48,7 @@ pub(crate) use delegation::{ pub(crate) use delivery::{ agent_runtime_delegation_id, dispatch_isolated_agent_join_at, game_creator_agent_runtime_terminal_status, publish_game_creator_agent_delegate_result, + reconcile_game_creator_agent_delegate_receipts_at, }; #[allow(unused_imports)] pub(crate) use isolated_joins::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/action_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/action_history.rs index 1f4682172..fcccfe18d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/action_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/action_history.rs @@ -276,8 +276,10 @@ pub(in crate::agent) fn read_agent_runtime_action_history( let persisted_safe_detail = agent_db_record_text(record, "safeDetail"); let safe_detail = if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { persisted_safe_detail.and_then(|value| { - agent_runtime_action_receipt_safe_detail( + agent_runtime_action_receipt_safe_detail_for_owner( root, + agent_id, + &run_id, &AgentRuntimeToolObservation { tool: record_tool.clone(), status: record_status.to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index 83fcb19da..c86494dab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -768,6 +768,7 @@ pub(crate) fn observe_agent_runtime_limited_command( "agentId": agent_id, "runId": run_id, "commandId": command.command_id, + "revision": revision.revision, "status": command.status, "logPath": command.log_path, "output": command.output, 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 fdb2fdc27..0f2815ff7 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 @@ -136,6 +136,33 @@ pub(in crate::agent) fn publish_game_creator_agent_delegate_result_for_state( root: &Path, state: &AgentRuntimeState, result_detail: Option<&str>, +) { + publish_game_creator_agent_delegate_result_for_state_with_project_lock( + root, + state, + result_detail, + false, + ); +} + +pub(in crate::agent) fn publish_game_creator_agent_delegate_result_for_state_at_locked( + root: &Path, + state: &AgentRuntimeState, + result_detail: Option<&str>, +) { + publish_game_creator_agent_delegate_result_for_state_with_project_lock( + root, + state, + result_detail, + true, + ); +} + +fn publish_game_creator_agent_delegate_result_for_state_with_project_lock( + root: &Path, + state: &AgentRuntimeState, + result_detail: Option<&str>, + project_lock_held: bool, ) { let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &state.agent_id, &state.run_id) @@ -144,7 +171,28 @@ pub(in crate::agent) fn publish_game_creator_agent_delegate_result_for_state( let Some(task) = task else { return; }; - publish_game_creator_agent_delegate_result(root, &task, result_detail); + if state.source != "agent-ready-task-scheduler" || state.delegation_id.is_some() { + publish_game_creator_agent_delegate_result(root, &task, result_detail); + } + let projection = if project_lock_held { + project_autonomous_manifest_ready_task_terminal_at_locked(root, state) + } else { + project_autonomous_manifest_ready_task_terminal_at(root, state) + }; + if let Err(error) = projection { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_ready_task.projection_failed", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + } } pub(in crate::agent) fn isolated_join_claim_action_id_from_cancelled_task( @@ -291,6 +339,80 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } +pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + let current_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + .ok_or_else(|| "manifest parent-wake 缺少父任务".to_string())?; + if current_task.status != "running" || current_task.phase != "waiting-for-manifest-tasks" { + return Ok(false); + } + if current_task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || current_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("manifest parent-wake 只允许自主构建根 Supervisor".to_string()); + } + + schedule_autonomous_game_build_ready_tasks_at( + root, + ¤t_task.agent_id, + ¤t_task.run_id, + 3, + )?; + if autonomous_manifest_dag_in_progress_at(root)? { + return Ok(false); + } + + let state = read_game_creator_agent_runtime_for_session_at( + root, + ¤t_task.agent_id, + Some(¤t_task.session_id), + )? + .state; + if state.run_id != current_task.run_id + || state.session_id != current_task.session_id + || state.current_task != current_task.task + || state.phase != "waiting-for-manifest-tasks" + { + return Err("manifest parent-wake 的父 run 状态身份不一致".to_string()); + } + if external_agent_runner_owns_background_execution() { + wake_external_agent_runner_pending_for_run( + root, + ¤t_task.agent_id, + ¤t_task.run_id, + state.loop_iteration, + )?; + return Ok(true); + } + + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, ¤t_task.agent_id)? + else { + return Ok(false); + }; + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "项目任务图已到达终态,父 run 正在继续验收", + "manifest 任务已完成或失败,恢复同一父 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; + }); + Ok(true) +} + pub(crate) fn dispatch_isolated_agent_join_at( root: &Path, join: JoinDispatch, @@ -701,12 +823,48 @@ pub(crate) fn publish_game_creator_agent_delegate_result( record_game_creator_agent_delegate_result_failure(root, child_task, &error); return; } - if game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) { + match existing_delivery.status { + StaticDelegateDeliveryStatus::ClaimedByParent => { + if existing_delivery.terminal_status.as_deref() != Some(terminal_status) { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + &format!("静态委派冻结终态与子任务终态冲突:{delegation_id}"), + ); + } + return; + } + StaticDelegateDeliveryStatus::Suppressed => return, + StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::Dispatched => {} + } + if game_creator_agent_runtime_terminal_status(&parent_task).is_some() + || game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) + { if let Err(error) = suppress_static_delegate_delivery_at(root, &existing_delivery) { record_game_creator_agent_delegate_result_failure(root, child_task, &error); } return; } + if existing_delivery.status == StaticDelegateDeliveryStatus::Ready { + if existing_delivery.terminal_status.as_deref() != Some(terminal_status) { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + &format!("静态委派冻结终态与子任务终态冲突:{delegation_id}"), + ); + return; + } + if parent_task.status == "running" + && parent_task.phase == "waiting-for-delegate-receipts" + { + schedule_waiting_static_delegate_parent_wake_after_lane_release( + root.to_path_buf(), + parent_agent_id.to_string(), + parent_run_id.to_string(), + ); + } + 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( @@ -1102,9 +1260,7 @@ pub(in crate::agent) fn schedule_static_delegate_parent_result_reconciliation_af }); } -pub(in crate::agent) fn reconcile_game_creator_agent_delegate_receipts_at( - root: &Path, -) -> Result<(), String> { +pub(crate) fn reconcile_game_creator_agent_delegate_receipts_at(root: &Path) -> Result<(), String> { for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? { let task_path = game_creator_agent_runtime_task_path(root, &agent_id); let tasks = latest_game_creator_agent_runtime_tasks( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 1efa71332..5cdb241f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -1,5 +1,27 @@ use super::*; +fn design_foundation_owned_project_path(path: &str) -> bool { + matches!(path, "memory/project.md" | "game/game_design.md") +} + +pub(in crate::agent) fn agent_role_project_path_mutation_block( + agent_id: &str, + tool: &str, + path: &str, +) -> Option { + if agent_id != "design-foundation" || design_foundation_owned_project_path(path) { + return None; + } + Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "design-foundation 只能修改项目基础设计文档".to_string(), + detail: Some(format!( + "path={path} · allowed=memory/project.md,game/game_design.md" + )), + }) +} + pub(in crate::agent) fn observe_agent_runtime_file( root: &Path, input: &serde_json::Value, @@ -159,6 +181,9 @@ pub(in crate::agent) fn observe_agent_runtime_file_write( }; } }; + if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.write", &path) { + return blocked; + } let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "file.write") { Ok(lock) => lock, @@ -247,6 +272,22 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( detail: None, }; } + let path = match normalize_relative_path(&path) + .and_then(|path| reject_agent_runtime_private_control_path(&path).map(|()| path)) + { + Ok(path) => path, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.delete", &path) { + return blocked; + } let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "file.delete", @@ -283,6 +324,30 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } + if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { + let manifest = match read_existing_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "blocked".to_string(), + summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + if manifest_has_required_visual_asset(root, &manifest, agent_id) { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "blocked".to_string(), + summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), + detail: Some( + "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" + .to_string(), + ), + }; + } + } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { @@ -421,6 +486,9 @@ pub(in crate::agent) fn observe_agent_runtime_file_patch( }; } }; + if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.patch", &path) { + return blocked; + } let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "file.patch") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 239e44b6c..e47871ba8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -69,6 +69,78 @@ impl AgentRuntimeUiPrototypeAssessment { } } +pub(crate) fn validate_agent_runtime_canvas_replacement_authorization_at( + root: &Path, + agent_id: &str, + run_id: &str, + output_path: &str, +) -> Result<(), String> { + let delegation_id = run_id + .strip_prefix("delegated-") + .ok_or_else(|| "只有静态专业 Agent 的唯一返工 run 可以替换正式图片".to_string())?; + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| "无法确认图片返工委派合同".to_string())?; + let original_delivery = delivery + .repair_of_delegation_id + .as_deref() + .map(|original_id| read_static_delegate_delivery_at(root, original_id)) + .transpose()? + .flatten(); + validate_static_delegate_repair_request_at( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.delegation_id, + &delivery.target_agent_id, + &delivery.acceptance_criteria, + &delivery.expected_artifacts, + delivery.repair_of_delegation_id.as_deref(), + )?; + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )? + .ok_or_else(|| "图片返工委派找不到有效父 run".to_string())?; + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .ok_or_else(|| "图片返工委派找不到有效 child run".to_string())?; + if delivery.target_agent_id != agent_id + || delivery.target_run_id != run_id + || delivery.status != StaticDelegateDeliveryStatus::Dispatched + || delivery.repair_of_delegation_id.is_none() + || !delivery + .expected_artifacts + .iter() + .any(|path| path == output_path) + || original_delivery.as_ref().is_none_or(|original| { + original.status != StaticDelegateDeliveryStatus::ClaimedByParent + || original.parent_agent_id != delivery.parent_agent_id + || original.parent_session_id != delivery.parent_session_id + || original.parent_run_id != delivery.parent_run_id + || original.target_agent_id != delivery.target_agent_id + || !original + .expected_artifacts + .iter() + .any(|path| path == output_path) + }) + || parent_task.agent_id != delivery.parent_agent_id + || parent_task.session_id != delivery.parent_session_id + || parent_task.run_id != delivery.parent_run_id + || parent_task.status != "running" + || child_task.agent_id != delivery.target_agent_id + || child_task.session_id != delivery.target_session_id + || child_task.run_id != delivery.target_run_id + || child_task.source != "agent-delegate" + || child_task.parent_agent_id.as_deref() != Some(delivery.parent_agent_id.as_str()) + || child_task.parent_run_id.as_deref() != Some(delivery.parent_run_id.as_str()) + || child_task.delegation_id.as_deref() != Some(delivery.delegation_id.as_str()) + || child_task.status != "running" + { + return Err("当前返工委派合同未授权替换该正式图片".to_string()); + } + Ok(()) +} + pub(in crate::agent) fn parse_agent_runtime_ui_prototype_assessment( response: &str, ) -> Result { @@ -414,6 +486,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio image_size: "2K".to_string(), asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), + replace_existing: false, }), "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), @@ -421,6 +494,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio image_size: "1K".to_string(), asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), + replace_existing: false, }), _ => None, }; @@ -429,14 +503,20 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio let image_size = agent_runtime_tool_input_text(input, &["imageSize", "image_size"]); let asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); let asset_label = agent_runtime_tool_input_text(input, &["assetLabel", "asset_label"]); + let replace_existing = input + .get("replaceExisting") + .or_else(|| input.get("replace_existing")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, image_size, asset_kind, asset_label, + replace_existing, }; - let options = if let Some(canonical) = canonical_options { + let mut options = if let Some(canonical) = canonical_options { let mismatch = requested_options .output_path .as_deref() @@ -453,8 +533,15 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), - summary: "图片产物型专业任务不能覆盖固定输出合同".to_string(), - detail: canonical.output_path.clone(), + summary: format!( + "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", + canonical.output_path.as_deref().unwrap_or("null"), + canonical.aspect_ratio, + canonical.image_size, + canonical.asset_kind, + canonical.asset_label, + ), + detail: None, }; } canonical @@ -482,8 +569,33 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } else { requested_options.asset_label }, + replace_existing, } }; + options.replace_existing = replace_existing; + if replace_existing { + let Some(output_path) = options.output_path.as_deref() else { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "替换图片时 outputPath 不能为空".to_string(), + detail: None, + }; + }; + if let Err(error) = validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } if !matches!( options.aspect_ratio.as_str(), "1:1" | "2:3" | "3:2" | "9:16" | "16:9" @@ -522,14 +634,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } - if let Err(error) = prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) - { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; + if !options.replace_existing { + if let Err(error) = + prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } } if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, "canvas.asset_generate") @@ -573,6 +688,25 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } + if options.replace_existing { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting preflight requires outputPath"); + if let Err(error) = validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } let mutation_revision = match prepare_agent_runtime_project_mutation_locked( root, agent_id, @@ -588,7 +722,18 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - match commit_prepared_platform_art_asset_at(root, prepared, &options) { + match commit_prepared_platform_art_asset_at(root, prepared, &options, |_| { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting commit guard requires outputPath"); + validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) + }) { Ok(generated) => { let verification = begin_agent_runtime_project_verification_locked( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 6710db107..9bf7aa3d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -4,6 +4,31 @@ fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") } +fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { + matches!( + command_id, + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "project.search" + | "file.read" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.write" + | "file.delete" + | "project.patchset" + | "task.list" + | "command.run_limited" + | "image.inspect" + | "canvas.asset_generate" + | "agent.audit" + | "agent.action_history" + | "agent.run_status" + ) +} + pub(in crate::agent) fn refresh_game_creator_agent_runtime_tool_policy( root: &Path, state: &mut AgentRuntimeState, @@ -51,6 +76,14 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( "自主构建模式只允许 design-foundation 或 art-asset-plan 执行:{command_id}" ))); } + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == "design-foundation" + && !autonomous_design_foundation_command_is_allowed(command_id) + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "design-foundation 只允许只读工具、受限设计文档写入、UI 原型画布生成和 game.static_smoke:{command_id}" + ))); + } match blocked { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => @@ -459,4 +492,105 @@ mod tests { if reason.contains("Agent 权限策略拒绝执行") )); } + + #[test] + fn autonomous_design_foundation_allows_only_role_scoped_tools() { + let temporary = tempfile::tempdir().expect("create design policy root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "project-design-role-boundary", + "设计基础角色工具边界测试", + ) + .expect("project init"); + let supervisor_run_id = "policy-design-parent-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind supervisor autonomous run"); + let binding = bind_delegated_autonomous_run(&root, supervisor_run_id, "design-foundation"); + for tool in [ + "memory.write", + "project.verify", + "project.checkpoint", + "project.restore", + "project.git_commit", + "task.create", + "task.update", + "command.exec", + "command.output_read", + "command.start", + "command.poll", + "command.stdin", + "command.terminate", + "preview.start", + "preview.validate", + "conversation.write", + "agent.delegate", + "agent.spawn_isolated", + "agent.schedule_ready", + "mcp.call", + ] { + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&binding.profile), + Some(&binding.binding_fingerprint), + tool, + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("design-foundation") + )); + } + for tool in [ + "memory.read", + "conversation.read", + "asset.list", + "project.index", + "project.search", + "file.read", + "project.diff", + "git.inspect", + "file.list", + "file.write", + "file.delete", + "project.patchset", + "task.list", + "command.run_limited", + "image.inspect", + "canvas.asset_generate", + "agent.audit", + "agent.action_history", + "agent.run_status", + ] { + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&binding.profile), + Some(&binding.binding_fingerprint), + tool, + ) + .is_none()); + } + + let code_binding = + bind_delegated_autonomous_run(&root, supervisor_run_id, "code-prototype"); + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "code-prototype", + "policy-code-prototype-run", + Some(&code_binding.profile), + Some(&code_binding.binding_fingerprint), + "project.verify", + ) + .is_none()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs index 9a8572f79..d797db151 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs @@ -188,6 +188,14 @@ where }; } }; + if agent_id == "design-foundation" { + if let Some(summary) = prepared.summaries().iter().find(|summary| { + agent_role_project_path_mutation_block(agent_id, tool, summary.path()).is_some() + }) { + return agent_role_project_path_mutation_block(agent_id, tool, summary.path()) + .expect("design-foundation unauthorized patchset path must be blocked"); + } + } let revision_before = match read_game_creator_agent_runtime_project_revision(root) { Ok(revision) => revision.revision, Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index 815905449..bf93ebda3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -5,12 +5,76 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( ) -> AgentRuntimeToolObservation { let result = read_manifest_for_project(root).map(|manifest| { let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks); + let seed_task_ids = new_game_creation_app_seed_tasks() + .into_iter() + .map(|task| task.id) + .collect::>(); + let seed_tasks = manifest + .tasks + .iter() + .filter(|task| seed_task_ids.contains(&task.id)) + .collect::>(); let ready_text = if ready_task_ids.is_empty() { "(none)".to_string() } else { ready_task_ids.join(", ") }; - let mut lines = vec![format!("readyTaskIds: {ready_text}")]; + let completed = manifest + .tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Completed) + .count(); + let running = manifest + .tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Running) + .count(); + let pending = manifest + .tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Pending) + .count(); + let waiting = manifest + .tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation) + .count(); + let failed = manifest + .tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Failed) + .count(); + let seed_completed = seed_tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Completed) + .count(); + let seed_running = seed_tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Running) + .count(); + let seed_pending = seed_tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Pending) + .count(); + let seed_waiting = seed_tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation) + .count(); + let seed_failed = seed_tasks + .iter() + .filter(|task| task.status == GameCreationAppTaskStatus::Failed) + .count(); + let mut lines = vec![ + format!("readyTaskIds: {ready_text}"), + format!( + "seedTaskCounts: completed={seed_completed} running={seed_running} pending={seed_pending} waiting={seed_waiting} failed={seed_failed} total={}", + seed_tasks.len() + ), + format!( + "taskCounts: completed={completed} running={running} pending={pending} waiting={waiting} failed={failed} total={}", + manifest.tasks.len() + ), + ]; lines.extend(manifest.tasks.iter().map(|task| { let dependencies = if task.dependencies.is_empty() { "-".to_string() 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 9f96f6f83..f69997290 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 @@ -845,7 +845,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置平台生成图片,写入确定的项目 assets 路径并登记素材。" + "通过已配置平台生成图片,写入确定的项目 assets 路径并登记素材;只有唯一返工委派可显式替换已登记正式图片。" } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", @@ -1061,7 +1061,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { }), "canvas.asset_generate" => json!({ "type": "object", - "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel"], + "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"], "additionalProperties": false, "properties": { "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, @@ -1069,7 +1069,8 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "aspectRatio": { "type": ["string", "null"], "enum": ["1:1", "2:3", "3:2", "9:16", "16:9", null] }, "imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] }, "assetKind": { "type": ["string", "null"], "enum": ["game-art", "ui-prototype", "art-spritesheet", null] }, - "assetLabel": { "type": ["string", "null"], "maxLength": 80 } + "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, + "replaceExisting": { "type": "boolean" } } }), "blackboard.write" => two_string_input_schema("title", "content"), 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 31866dc59..fad456437 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -827,7 +827,8 @@ pub(crate) fn register_local_asset_entry( } else { media_type }; - let source_for_record = source.clone(); + let mut source_for_record = source.clone(); + source_for_record.prompt = None; let (id, record_type) = if let Some(existing) = manifest .assets 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 40255bda5..27dd50c66 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 @@ -81,7 +81,7 @@ pub(crate) fn record_preview_state( ensure_manifest_seed_tasks(root, &mut manifest); let is_running = status == GameCreationAppPreviewStatus::Running; manifest.preview = Some(GameCreationAppPreviewState { status, url, port }); - if is_running { + if is_running && !autonomous_game_build_root_run_active_at(root) { set_task_status( &mut manifest, "preview-playtest", @@ -97,7 +97,8 @@ pub(crate) fn record_command_run( ) -> Result<(), String> { let (manifest_path, mut manifest) = read_or_create_manifest(root)?; ensure_manifest_seed_tasks(root, &mut manifest); - if run.command_id == "game.static_smoke" + if !autonomous_game_build_root_run_active_at(root) + && run.command_id == "game.static_smoke" && run.status == GameCreationAppCommandRunStatus::Completed { set_task_status( @@ -195,6 +196,7 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio return; } + let visual_assets_required = editor_api_key_is_configured(); for seed_task in seed_tasks { let visual_asset_ready = manifest_has_required_visual_asset(root, manifest, &seed_task.id); if let Some(existing_task) = manifest @@ -203,6 +205,7 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio .find(|task| task.id == seed_task.id) { let status = if existing_task.status == GameCreationAppTaskStatus::Completed + && visual_assets_required && matches!( seed_task.id.as_str(), "design-foundation" | "art-asset-plan" @@ -221,7 +224,7 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio } } -fn manifest_has_required_visual_asset( +pub(crate) fn manifest_has_required_visual_asset( root: &Path, manifest: &GameCreationAppManifest, task_id: &str, 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 3a582c35f..67c225b94 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 @@ -1154,7 +1154,8 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() { "aspectRatio": "1:1", "imageSize": "2K", "assetKind": "game-art", - "assetLabel": "错误标签" + "assetLabel": "错误标签", + "replaceExisting": false }), }; @@ -1169,11 +1170,17 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() { .await; assert_eq!(observation.status, "failed"); - assert!(observation.summary.contains("不能覆盖固定输出合同")); - assert_eq!( - observation.detail.as_deref(), - Some("assets/ui-prototype.png") - ); + for expected in [ + "不能覆盖固定输出合同", + "outputPath=assets/ui-prototype.png", + "aspectRatio=16:9", + "imageSize=2K", + "assetKind=ui-prototype", + "assetLabel=游戏横屏界面原型图", + ] { + assert!(observation.summary.contains(expected)); + } + assert_eq!(observation.detail, None); assert!(!root.join("assets/wrong-prototype.png").exists()); assert!(read_manifest_for_project(&root) .expect("manifest after rejected override") @@ -1189,7 +1196,8 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() { "aspectRatio": "16:9", "imageSize": "1K", "assetKind": "ui-prototype", - "assetLabel": "游戏横屏界面原型图" + "assetLabel": "游戏横屏界面原型图", + "replaceExisting": false }), }; let one_k_observation = execute_game_creator_agent_runtime_tool_action_with_action_id( @@ -1209,6 +1217,9 @@ 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 _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-semantic-gate-test-key"}}"#.to_string(), + ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "UI 原型语义完成门禁测试") .expect("project init"); @@ -1394,6 +1405,397 @@ async fn supervisor_collaboration_canvas_asset_generate_rechecks_after_project_l fs::remove_dir_all(root).ok(); } +#[test] +fn canvas_replacement_requires_the_claimed_original_and_unique_repair_contract() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-canvas-replacement-contract", + "画布正式图片返工合同测试", + ) + .expect("project init"); + let output_path = "assets/art-spritesheet.png"; + let expected_artifacts = vec![output_path.to_string()]; + let original_id = "canvas-replacement-original"; + let mut original = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "canvas-replacement-parent-session", + "canvas-replacement-parent-run", + "canvas-replacement-original-action", + original_id, + "art-asset-plan", + "canvas-replacement-child-session", + "delegated-canvas-replacement-original", + &["交付原创首版美术".to_string()], + &expected_artifacts, + None, + ); + original.status = StaticDelegateDeliveryStatus::ClaimedByParent; + original.terminal_status = Some("completed".to_string()); + original.result_summary = Some("首版图片已交付,语义复核要求返工".to_string()); + original.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::EvidenceReady, + artifacts: vec![StaticDelegateArtifact { + path: output_path.to_string(), + sha256: "a".repeat(64), + }], + missing_expected_artifacts: Vec::new(), + verification_required: false, + verified_revision: None, + evidence: Vec::new(), + error: None, + }); + original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string()); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("persist claimed original delivery"); + + let repair_id = "canvas-replacement-repair"; + let repair_run_id = format!("delegated-{repair_id}"); + let repair = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "canvas-replacement-parent-session", + "canvas-replacement-parent-run", + "canvas-replacement-repair-action", + repair_id, + "art-asset-plan", + "canvas-replacement-child-session", + &repair_run_id, + &["交付原创首版美术".to_string()], + &expected_artifacts, + Some(original_id), + ); + create_or_read_static_delegate_delivery_at(&root, &repair) + .expect("persist unique repair delivery"); + let parent_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: "canvas-replacement-parent-session".to_string(), + run_id: "canvas-replacement-parent-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: "复核并安排正式图片返工".to_string(), + status: "running".to_string(), + phase: "waiting-for-delegate-receipts".to_string(), + current_action: "等待返工图片".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &parent_task); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-asset-plan".to_string(), + task_id: "art-asset-plan".to_string(), + session_id: "canvas-replacement-child-session".to_string(), + run_id: repair_run_id.clone(), + source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("canvas-replacement-parent-run".to_string()), + delegation_id: Some(repair_id.to_string()), + task: "替换不符合原创要求的正式图片".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "生成返工图片".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + + validate_agent_runtime_canvas_replacement_authorization_at( + &root, + "art-asset-plan", + &repair_run_id, + output_path, + ) + .expect("claimed original and repair contract authorize replacement"); + assert!(validate_agent_runtime_canvas_replacement_authorization_at( + &root, + "art-asset-plan", + &repair_run_id, + "assets/other.png", + ) + .expect_err("repair cannot replace an undeclared path") + .contains("未授权")); + assert!(validate_agent_runtime_canvas_replacement_authorization_at( + &root, + "art-asset-plan", + "ordinary-art-run", + output_path, + ) + .expect_err("ordinary run cannot replace a formal image") + .contains("唯一返工")); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "父 run 已失败".to_string(), + terminal_detail: Some("父 run 失败".to_string()), + error: Some("parent-failed".to_string()), + updated_at: unix_timestamp(), + ..parent_task.clone() + }, + ); + assert!(validate_agent_runtime_canvas_replacement_authorization_at( + &root, + "art-asset-plan", + &repair_run_id, + output_path, + ) + .expect_err("failed parent run must revoke replacement authorization") + .contains("未授权")); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + session_id: "drifted-parent-session".to_string(), + updated_at: unix_timestamp(), + ..parent_task + }, + ); + assert!(validate_agent_runtime_canvas_replacement_authorization_at( + &root, + "art-asset-plan", + &repair_run_id, + output_path, + ) + .expect_err("parent identity drift must revoke replacement authorization") + .contains("未授权")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_request() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let (generation_release_sender, generation_release_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server_with_gate( + request_sender, + generation_release_receiver, + ); + init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow canvas generation"); + fs::write(root.join("assets/art-spritesheet.png"), b"original-image") + .expect("write original image"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_session_id = "canvas-repair-parent-session"; + let parent_run_id = "canvas-repair-parent-run"; + let child_agent_id = "art-asset-plan"; + let child_session_id = "canvas-repair-child-session"; + let original_id = "canvas-repair-original"; + let repair_id = "canvas-repair-retry"; + let repair_run_id = format!("delegated-{repair_id}"); + let output_path = "assets/art-spritesheet.png"; + let acceptance_criteria = vec!["交付原创首版美术".to_string()]; + let expected_artifacts = vec![output_path.to_string()]; + let mut original = new_static_delegate_delivery_with_contract( + parent_agent_id, + parent_session_id, + parent_run_id, + "canvas-repair-original-action", + original_id, + child_agent_id, + child_session_id, + "delegated-canvas-repair-original", + &acceptance_criteria, + &expected_artifacts, + None, + ); + original.status = StaticDelegateDeliveryStatus::ClaimedByParent; + original.terminal_status = Some("completed".to_string()); + original.result_summary = Some("首版图片需要返工".to_string()); + original.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::EvidenceReady, + artifacts: vec![StaticDelegateArtifact { + path: output_path.to_string(), + sha256: "a".repeat(64), + }], + missing_expected_artifacts: Vec::new(), + verification_required: false, + verified_revision: None, + evidence: Vec::new(), + error: None, + }); + original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string()); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("persist claimed original delivery"); + let repair = new_static_delegate_delivery_with_contract( + parent_agent_id, + parent_session_id, + parent_run_id, + "canvas-repair-retry-action", + repair_id, + child_agent_id, + child_session_id, + &repair_run_id, + &acceptance_criteria, + &expected_artifacts, + Some(original_id), + ); + create_or_read_static_delegate_delivery_at(&root, &repair).expect("persist repair delivery"); + let parent_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: parent_agent_id.to_string(), + task_id: parent_agent_id.to_string(), + session_id: parent_session_id.to_string(), + run_id: parent_run_id.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: "复核并安排正式图片返工".to_string(), + status: "running".to_string(), + phase: "waiting-for-delegate-receipts".to_string(), + current_action: "等待返工图片".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &parent_task); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: child_agent_id.to_string(), + task_id: child_agent_id.to_string(), + session_id: child_session_id.to_string(), + run_id: repair_run_id.clone(), + source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: Some(parent_agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(repair_id.to_string()), + task: "替换不符合原创要求的正式图片".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "调用画布生成返工图片".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": canvas_base_url, + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before request"); + let request_root = root.clone(); + let request_config_dir = config_dir.clone(); + let request_run_id = repair_run_id.clone(); + let request = std::thread::spawn(move || { + let _config_guard = use_test_runtime_config_dir(request_config_dir); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build canvas request runtime") + .block_on( + observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test( + &request_root, + child_agent_id, + &request_run_id, + "替换不符合原创要求的正式图片", + &serde_json::json!({ + "prompt": "生成原创晶体与潮汐构装体图集", + "replaceExisting": true + }), + ), + ) + }); + + let mut saw_generation_request = false; + for _ in 0..3 { + 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/images/generations ") { + saw_generation_request = true; + break; + } + } + assert!(saw_generation_request); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "父 run 已取消".to_string(), + terminal_detail: Some("取消返工".to_string()), + updated_at: unix_timestamp(), + ..parent_task + }, + ); + generation_release_sender + .send(()) + .expect("release mock generation response"); + let observation = request.join().expect("join canvas request"); + + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!(observation.summary.contains("未授权")); + assert_eq!( + fs::read(root.join(output_path)).expect("read preserved image"), + b"original-image" + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after rejected commit"), + revision_before + ); + assert_eq!( + fs::read_dir(root.join("assets")) + .expect("read assets directory") + .count(), + 1, + "authorization failure must not leave replacement files" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[test] fn delegated_agent_retry_is_rejected_after_parent_runtime_terminates() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index 0ed996292..08e261524 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -304,6 +304,238 @@ fn project_supervisor_terminal_parent_suppresses_late_matching_child_delivery() fs::remove_dir_all(root).ok(); } +#[test] +fn claimed_static_delivery_ignores_republished_terminal_projection() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "已认领回执重复投递测试").expect("project init"); + let parent_run_id = "project-supervisor-claimed-replay-parent"; + let parent_action_id = "project-supervisor-claimed-replay-action"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "code-prototype", + parent_action_id, + ); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待程序交付", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["认领程序回执".to_string()], + ) + .expect("start parent runtime"); + fs::write( + root.join("game/index.html"), + "冻结回执版本一", + ) + .expect("write first artifact version"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + "code-prototype", + "project-supervisor-claimed-replay-child-session", + "project-supervisor-claimed-replay-child-run", + &["交付可玩的游戏入口".to_string()], + &["game/index.html".to_string()], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "code-prototype".to_string(), + task_id: "code-prototype".to_string(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + task: "生成可玩入口".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "程序交付已完成".to_string(), + terminal_detail: Some("首次冻结的程序交付摘要".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + let receipts = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "project-supervisor-claimed-replay-claim", + ) + .expect("claim ready receipt"); + assert_eq!(receipts.len(), 1); + mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "project-supervisor-claimed-replay-claim", + ) + .expect("observe claim"); + let claimed_before = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read claimed delivery before recovery") + .expect("claimed delivery exists before recovery"); + assert_eq!( + claimed_before.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + let failures_before = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.agent.delegate.result_failed" + && record["delegationId"] == delegation_id + }) + .count(); + + fs::write( + root.join("game/index.html"), + "后续 revision 版本二", + ) + .expect("write later artifact version"); + reconcile_game_creator_agent_delegate_receipts_at(&root) + .expect("reconcile already claimed terminal delivery"); + + let persisted = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read claimed delivery") + .expect("claimed delivery exists"); + assert_eq!(persisted, claimed_before); + assert_eq!( + persisted.result_summary.as_deref(), + Some("首次冻结的程序交付摘要") + ); + let failures_after = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.agent.delegate.result_failed" + && record["delegationId"] == delegation_id + }) + .count(); + assert_eq!(failures_after, failures_before); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn restart_reconciliation_suppresses_ready_delivery_for_terminal_parent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Ready 回执重启 suppression 测试") + .expect("project init"); + let parent_run_id = "project-supervisor-ready-replay-parent"; + let parent_action_id = "project-supervisor-ready-replay-action"; + let target_agent_id = "design-director"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + parent_action_id, + ); + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待设计回执", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["认领设计回执".to_string()], + ) + .expect("start parent runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + target_agent_id, + "project-supervisor-ready-replay-child-session", + "project-supervisor-ready-replay-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: target_agent_id.to_string(), + task_id: target_agent_id.to_string(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + task: "返回设计结论".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "设计结论已完成".to_string(), + terminal_detail: Some("已冻结的设计结论".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + publish_game_creator_agent_delegate_result(&root, &child_task, None); + assert_eq!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read ready delivery") + .expect("ready delivery exists") + .status, + StaticDelegateDeliveryStatus::Ready + ); + + let mut terminal_parent = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read parent task before terminal transition") + .expect("parent task exists before terminal transition"); + terminal_parent.status = "completed".to_string(); + terminal_parent.phase = "completed".to_string(); + terminal_parent.current_action = "父任务已完成".to_string(); + terminal_parent.terminal_detail = Some("重启前父任务已经完成".to_string()); + terminal_parent.updated_at = unix_timestamp(); + write_agent_runtime_task_record_for_test(&root, &terminal_parent); + let failures_before = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.agent.delegate.result_failed" + && record["delegationId"] == delegation_id + }) + .count(); + + reconcile_game_creator_agent_delegate_receipts_at(&root) + .expect("reconcile ready delivery after restart"); + + let suppressed = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read reconciled delivery") + .expect("reconciled delivery exists"); + assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed); + let failures_after = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.agent.delegate.result_failed" + && record["delegationId"] == delegation_id + }) + .count(); + assert_eq!(failures_after, failures_before); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_delivery_previous_recovers_read_list_and_barrier() { let root = unique_project_path(); 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 eb88f284c..7ea41b081 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 @@ -38,6 +38,9 @@ fn project_supervisor_runtime_id_is_normalized_and_collected() { #[test] fn visual_specialist_prompts_require_real_registered_image_deliveries() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-prompt-test-key"}}"#.to_string(), + ); let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("design-foundation"); for expected in [ @@ -51,6 +54,11 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "image.inspect", "ui-prototype.v1", "resourceBar", + "原创标题、阵营、资源、单位名称与视觉语言", + "Peashooter", + "canvas.asset_generate 成功动作本身就是当前 revision 的验证", + "已有同路径画布资产时先用 asset.list", + "检查已通过时不得删除、重复生成或再次扣费", "file.delete", "纯场景图", "不得把计划写完当成 completed", @@ -65,10 +73,15 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { for expected in [ "资产清单和美术计划只是中间结果", "canvas.asset_generate", + "assets/manifest.art.json", "assets/art-spritesheet.png", + "aspectRatio=1:1", + "imageSize=1K", "assetKind=art-spritesheet", "该动作本身就是当前 revision 的验证", - "随后只需调用 asset.list", + "随后调用 asset.list", + "不得删除、重复生成或扣费", + "不需要调用 image.inspect 做主观返工", "asset.list", "不得运行 game.static_smoke 或 preview.validate", "不得编辑 game/index.html", @@ -86,11 +99,37 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { assert!(!ordinary_prompt.contains("assets/art-spritesheet.png")); } -#[tokio::test] -async fn project_supervisor_prompts_are_total_control_and_reject_isolated_template() { - let planning_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( +#[test] +fn visual_prompts_degrade_to_text_contracts_without_editor_api_key() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let design_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("design-foundation"); + assert!(design_prompt.contains("当前未配置 External Editor API Key")); + assert!(design_prompt.contains("memory/project.md 与 game/game_design.md")); + assert!(design_prompt.contains("不调用 canvas.asset_generate")); + + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-asset-plan"); + assert!(art_prompt.contains("assets/manifest.art.json")); + assert!(art_prompt.contains("不伪造 assets/art-spritesheet.png")); + + let supervisor_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, ); + assert!(supervisor_prompt.contains("当前未配置 External Editor API Key")); + assert!(supervisor_prompt.contains("不得要求调用 canvas.asset_generate")); + assert!(!supervisor_prompt.contains("design-foundation 是图片产物型任务")); +} + +#[tokio::test] +async fn project_supervisor_prompts_are_total_control_and_reject_isolated_template() { + let planning_prompt = { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"supervisor-prompt-test-key"}}"#.to_string(), + ); + game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + }; for expected in [ "唯一面向用户的 Project Supervisor", "用户原始目标", 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 968ae7694..f8365d780 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 @@ -252,6 +252,9 @@ fn rewrite_autonomous_responsibility_batch_actions_for_test( async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_without_side_effects() { let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated no-key runtime config dir"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); init_local_game_project_at( &root, "project-supervisor-autonomous-durable-responsibility", @@ -433,6 +436,7 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi ); fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } #[tokio::test] 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 f0fd36f0c..88b478857 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 @@ -2228,6 +2228,20 @@ fn project_verification_gate_accepts_only_exact_game_static_smoke() { fs::remove_dir_all(root).ok(); } +#[test] +fn successful_canvas_generation_verifies_prior_visual_replacement() { + let observations = vec![ + verification_gate_observation("file.delete", "ok", "已删除 assets/ui-prototype.png"), + verification_gate_observation( + "canvas.asset_generate", + "ok", + "已生成美术素材:assets/ui-prototype.png", + ), + ]; + + assert!(project_verification_completion_blocker(&observations).is_none()); +} + #[test] fn project_verification_sidecar_failure_blocks_completion() { let root = unique_project_path(); 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 77a2d3711..f57e0a42b 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 @@ -1239,6 +1239,15 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { manifest["assets"][0]["source"]["canvasProjectId"], "canvas-project-1" ); + assert_eq!(manifest["assets"][0]["source"]["prompt"], "像素主角"); + let asset_record = fs::read_to_string(root.join(".agent/agent.db")) + .expect("agent db") + .lines() + .map(|line| serde_json::from_str::(line).expect("agent db record")) + .find(|record| record["recordType"] == "asset.register") + .expect("asset register record"); + assert!(asset_record["source"].get("prompt").is_none()); + assert_eq!(asset_record["source"]["model"], "image-model"); let updated = register_local_asset_at( &root, @@ -2820,6 +2829,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { image_size: "2K".to_string(), asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), + replace_existing: false, }; let prompt = build_platform_art_asset_prompt( "原创花园防守玩法,需要清楚的资源、波次和操作信息", @@ -2834,6 +2844,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { "敌人来袭方向", "开始、暂停、重开控件", "禁止只画", + "玩法类型和机制词只用于理解功能", + "转换为新的原创命名", + "Peashooter", "原创花园防守玩法", ] { assert!( @@ -2845,10 +2858,53 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { let art_spec = platform_art_asset_art_spec(&options); assert_eq!(art_spec["assetType"], "ui"); assert_eq!(art_spec["format"], "16:9 2K"); - for expected in ["HUD", "单位卡槽", "波次", "暂停", "无 HUD 的场景插画"] { + for expected in [ + "HUD", + "单位卡槽", + "波次", + "暂停", + "无 HUD 的场景插画", + "原创标题", + "单位名", + ] { assert!( art_spec.to_string().contains(expected), "UI art spec missing {expected}" ); } } + +#[test] +fn art_spritesheet_generation_prompt_enforces_original_visual_language() { + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/art-spritesheet.png".to_string()), + asset_kind: "art-spritesheet".to_string(), + asset_label: "游戏首版核心美术素材".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = build_platform_art_asset_prompt("制作原创植物塔防小游戏", &[], &options); + for expected in [ + "原创核心美术素材图集", + "带脸向日葵", + "嘴状豌豆炮管", + "僵尸人形", + "不得把知名角色改色", + "晶体、菌丝、雾气、陶质器物、潮汐生态构装体", + ] { + assert!( + prompt.contains(expected), + "art generation prompt missing {expected}" + ); + } + + let art_spec = platform_art_asset_art_spec(&options).to_string(); + for expected in [ + "原创 Web 游戏素材图集", + "带脸向日葵", + "嘴状豌豆炮管", + "草坪横排", + "植物或生态主题不得直接画成", + ] { + assert!(art_spec.contains(expected), "art spec missing {expected}"); + } +} 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 6bce35bef..97a8d81cb 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 @@ -2160,7 +2160,7 @@ async fn agent_runtime_file_delete_rejects_unsafe_and_agent_control_paths() { read_game_creator_agent_runtime_project_revision(&root) .expect("read conservative delete revision") .revision, - 8 + 4 ); let directory_gate = read_game_creator_agent_runtime_verification_gate( &root, @@ -2175,17 +2175,14 @@ async fn agent_runtime_file_delete_rejects_unsafe_and_agent_control_paths() { ) .expect("read runtime private delete gate"); assert!(directory_gate.requires_verification); - assert_eq!(directory_gate.mutation_revision, Some(4)); + assert_eq!(directory_gate.mutation_revision, Some(1)); assert_eq!( directory_gate.last_mutation_tool.as_deref(), Some("file.delete") ); - assert!(private_gate.requires_verification); - assert_eq!(private_gate.mutation_revision, Some(5)); - assert_eq!( - private_gate.last_mutation_tool.as_deref(), - Some("file.delete") - ); + assert!(!private_gate.requires_verification); + assert_eq!(private_gate.mutation_revision, None); + assert_eq!(private_gate.last_mutation_tool, None); assert!(!read_agent_db_records_for_test(&root) .iter() .any(|record| record["recordType"] == "agent.runtime.file.delete")); @@ -4358,6 +4355,257 @@ fn agent_runtime_action_receipt_rejects_corrupt_agent_db_middle() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_runtime_preview_receipt_binds_relative_evidence_to_owner_and_revision() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-preview-history", "预览证据回查测试") + .expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "回查双视口证据", + "preview-action-history-run", + "agent-background-task", + "读取安全预览证据", + vec!["只返回项目内相对路径".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证当前 revision".to_string()), + input: serde_json::json!({}), + }; + let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let append_preview_receipt = |action_index: u32, + occurrence_nonce: u64, + evidence_agent_id: &str, + evidence_run_id: &str, + evidence_revision: u64, + observation_revision: u64| { + let action_id = agent_runtime_tool_action_id( + &runtime.run_id, + 1, + action_index, + occurrence_nonce, + &fingerprint, + ); + let evidence_root = format!( + ".agent/runtime/browser-validations/{evidence_agent_id}/{evidence_run_id}/{evidence_revision}" + ); + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &fingerprint, + "preview.validate", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("viewports=desktop,mobile"), + &AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "ok".to_string(), + summary: "浏览器验证已通过,已生成桌面与移动证据".to_string(), + detail: Some( + serde_json::json!({ + "passed": true, + "revision": observation_revision, + "reportPath": format!("{evidence_root}/validation.json"), + "screenshots": [ + format!("{evidence_root}/desktop.png"), + format!("{evidence_root}/mobile.png"), + ], + "diagnostics": [], + "playtest": {"passed": true, "scenario": "lane-defense-v1"}, + "privateAbsolutePath": "/home/example/private/screenshot.png", + }) + .to_string(), + ), + }, + ) + .expect("append preview receipt"); + action_id + }; + let action_id = append_preview_receipt(0, 29, &runtime.agent_id, &runtime.run_id, 7, 7); + let cross_agent_action_id = + append_preview_receipt(1, 31, "quality-review", &runtime.run_id, 7, 7); + let cross_run_action_id = append_preview_receipt( + 2, + 37, + &runtime.agent_id, + "preview-action-history-other-run", + 7, + 7, + ); + let historical_revision_action_id = + append_preview_receipt(3, 41, &runtime.agent_id, &runtime.run_id, 6, 7); + + let records = read_agent_db_records_for_test(&root); + let valid_receipt = records + .iter() + .find(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .expect("bound preview action receipt"); + let safe_detail = serde_json::from_str::( + valid_receipt["safeDetail"] + .as_str() + .expect("bound preview safe detail"), + ) + .expect("parse bound preview safe detail"); + let evidence_root = format!( + ".agent/runtime/browser-validations/{}/{}/7", + runtime.agent_id, runtime.run_id + ); + assert_eq!(safe_detail["revision"], 7); + assert_eq!( + safe_detail["reportPath"], + format!("{evidence_root}/validation.json") + ); + assert_eq!( + safe_detail["screenshots"], + serde_json::json!([ + format!("{evidence_root}/desktop.png"), + format!("{evidence_root}/mobile.png"), + ]) + ); + assert_eq!(valid_receipt["detailUnavailable"], false); + for rejected_action_id in [ + &cross_agent_action_id, + &cross_run_action_id, + &historical_revision_action_id, + ] { + let receipt = records + .iter() + .find(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == rejected_action_id.as_str() + }) + .expect("rejected preview action receipt"); + assert!(receipt["safeDetail"].is_null()); + assert_eq!(receipt["detailUnavailable"], true); + } + + let history = observe_agent_runtime_action_history( + &root, + &runtime.agent_id, + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(history.status, "ok"); + let detail = history.detail.expect("preview history detail"); + assert!(detail.contains("desktop.png")); + assert!(detail.contains("mobile.png")); + assert!(detail.contains("validation.json")); + assert!(detail.contains("\"detailUnavailable\":false")); + assert!(!detail.contains("/home/example/private")); + assert!(!detail.contains("privateAbsolutePath")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains("/home/example/private")); + assert!(!agent_db.contains("preview-action-history-other-run")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_preview_receipt_history_rejects_forged_owner_and_revision_paths() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-preview-forged-history", + "伪造预览证据回查测试", + ) + .expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "拒绝伪造双视口证据", + "preview-forged-history-run", + "agent-background-task", + "读取安全预览证据", + vec!["只返回当前 Agent、run 和 revision 的证据".to_string()], + ) + .expect("start runtime"); + let forged_cases = [ + ( + "action-111111111111111111111111", + "1".repeat(64), + format!( + ".agent/runtime/browser-validations/quality-review/{}/7", + runtime.run_id + ), + 7, + ), + ( + "action-222222222222222222222222", + "2".repeat(64), + format!( + ".agent/runtime/browser-validations/{}/preview-forged-other-run/7", + runtime.agent_id + ), + 7, + ), + ( + "action-333333333333333333333333", + "3".repeat(64), + format!( + ".agent/runtime/browser-validations/{}/{}/6", + runtime.agent_id, runtime.run_id + ), + 7, + ), + ]; + for (action_id, action_fingerprint, evidence_root, revision) in &forged_cases { + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "tool": "preview.validate", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "status": "ok", + "summary": "伪造预览证据不得进入动作历史", + "safeDetail": serde_json::json!({ + "passed": true, + "revision": revision, + "reportPath": format!("{evidence_root}/validation.json"), + "screenshots": [ + format!("{evidence_root}/desktop.png"), + format!("{evidence_root}/mobile.png"), + ], + "diagnosticsCount": 0, + "playtestPassed": true, + "playtestScenario": "lane-defense-v1", + }) + .to_string(), + "detailUnavailable": false, + }), + ) + .expect("append forged preview receipt"); + + let history = observe_agent_runtime_action_history( + &root, + &runtime.agent_id, + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(history.status, "ok"); + let detail = history.detail.expect("forged preview history detail"); + assert!(detail.contains("\"detailUnavailable\":true")); + assert!(!detail.contains(evidence_root)); + assert!(!detail.contains("desktop.png")); + assert!(!detail.contains("mobile.png")); + assert!(!detail.contains("validation.json")); + } + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_action_history_folds_receipts_and_legacy_observations() { 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 d3adf71a5..237894153 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 @@ -7199,7 +7199,8 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "aspectRatio", "imageSize", "assetKind", - "assetLabel" + "assetLabel", + "replaceExisting" ]) ); assert_eq!( 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 32ecff1c8..e8bdf8e91 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 @@ -951,6 +951,9 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof #[test] fn visual_specialist_finalization_requires_existing_registered_canvas_image() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-finalization-test-key"}}"#.to_string(), + ); for (agent_id, local_path, kind) in [ ( "design-foundation", 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 e2baecb45..118a39c7b 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 @@ -634,6 +634,18 @@ async fn background_agent_runtime_can_run_limited_static_smoke() { let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"agent.runtime.command.run_limited\"")); assert!(agent_db.contains("\"commandId\":\"game.static_smoke\"")); + let project_revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read project revision") + .revision; + let smoke_record = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|record| { + record["recordType"] == "agent.runtime.command.run_limited" + && record["commandId"] == "game.static_smoke" + }) + .expect("static smoke record"); + assert_eq!(smoke_record["revision"].as_u64(), Some(project_revision)); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); @@ -1529,6 +1541,117 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn art_asset_plan_cannot_delete_registered_fixed_visual_asset() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "固定美术资产删除门禁").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow delete tools"); + + let action = AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("尝试删除已登记的固定美术素材".to_string()), + input: serde_json::json!({ "path": "assets/art-spritesheet.png" }), + }; + let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "art-asset-plan", + "art-delete-protection-run", + "已登记的固定美术素材必须复用", + &action, + Some("art-delete-protection-action"), + ) + .await; + + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("禁止删除固定正式产物")); + assert!(root.join("assets/art-spritesheet.png").is_file()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision, + 0 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn design_foundation_file_tools_cannot_modify_the_game_entry() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "设计基础文件边界测试").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file tools"); + let original = fs::read_to_string(root.join("game/index.html")).expect("read game entry"); + for action in [ + AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("越权改写程序入口".to_string()), + input: serde_json::json!({ + "path": "game/index.html", + "content": "

unauthorized

" + }), + }, + AgentRuntimeToolAction { + tool: "project.patchset".to_string(), + reason: Some("越权批量改写程序入口".to_string()), + input: serde_json::json!({ + "changes": [{ + "operation": "update", + "path": "game/index.html", + "expectedSha256": format!("{:x}", Sha256::digest(original.as_bytes())), + "oldText": original.clone(), + "newText": "

unauthorized patchset

", + "expectedReplacements": 1 + }] + }), + }, + AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("越权删除程序入口".to_string()), + input: serde_json::json!({ "path": "game/index.html" }), + }, + ] { + let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "design-foundation", + "design-foundation-boundary-run", + "只允许交付项目基础设计文档", + &action, + Some("design-foundation-boundary-action"), + ) + .await; + assert_eq!(observation.status, "blocked", "{observation:?}"); + assert!(observation.summary.contains("只能修改项目基础设计文档")); + } + assert_eq!( + fs::read_to_string(root.join("game/index.html")).expect("read unchanged game entry"), + original + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read unchanged revision") + .revision, + 0 + ); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_image_inspect_sends_two_images_without_persisting_payloads() { let root = unique_project_path(); @@ -1793,6 +1916,9 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-seed-refresh-test-key"}}"#.to_string(), + ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧视觉任务升级测试").expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); 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 e7423d118..ba3a41b23 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 @@ -1,19 +1,59 @@ use super::super::support::*; use crate::{ - active_static_delegate_delivery_count_at, bind_supervisor_collaboration_policy_snapshot_at, - build_static_delegate_structured_result_at, claim_ready_static_delegate_receipts_at, - create_or_read_static_delegate_delivery_at, mark_static_delegate_claim_observed_at, - mark_static_delegate_delivery_ready_at, mark_static_delegate_delivery_ready_with_result_at, + active_static_delegate_delivery_count_at, autonomous_game_build_root_run_active_at, + bind_supervisor_collaboration_policy_snapshot_at, build_static_delegate_structured_result_at, + claim_ready_static_delegate_receipts_at, create_or_read_static_delegate_delivery_at, + mark_static_delegate_claim_observed_at, mark_static_delegate_delivery_ready_at, + mark_static_delegate_delivery_ready_with_result_at, new_game_creation_app_seed_tasks, new_static_delegate_delivery, new_static_delegate_delivery_with_contract, - observe_agent_runtime_run_status, + observe_agent_runtime_run_status, record_command_run, record_preview_state, refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at, + start_game_creator_supervisor_background_task_for_session_at, static_delegate_completion_barrier_at, validate_agent_runtime_autonomous_plan_liveness, + GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppPreviewStatus, StaticDelegateContractStatus, }; +fn write_autonomous_editor_api_config_for_test(config_dir: &Path, api_key: &str) { + fs::create_dir_all(config_dir).expect("create autonomous runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": api_key + } + }) + .to_string(), + ) + .expect("write autonomous Editor API config"); +} + +fn autonomous_seed_task_statuses_for_test( + root: &Path, +) -> BTreeMap { + let manifest = read_manifest_for_project(root).expect("read autonomous manifest"); + new_game_creation_app_seed_tasks() + .into_iter() + .map(|seed_task| { + let status = manifest + .tasks + .iter() + .find(|task| task.id == seed_task.id) + .unwrap_or_else(|| panic!("missing seed task {}", seed_task.id)) + .status + .clone(); + (seed_task.id, status) + }) + .collect() +} + #[test] fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { let root = unique_project_path(); + let config_dir = unique_project_path(); + write_autonomous_editor_api_config_for_test(&config_dir, ""); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); init_local_game_project_at(&root, "project-autonomous-profile", "自主构建权限项目") .expect("project init"); let run_id = "autonomous-profile-root-run"; @@ -157,11 +197,11 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { Some(&art_binding.binding_fingerprint), ) .expect("autonomous art policy snapshot"); - assert!(art_policy + assert!(!art_policy .auto_tools .iter() .any(|tool| tool == "canvas.asset_generate")); - assert!(!art_policy + assert!(art_policy .denied_tools .iter() .any(|tool| tool == "canvas.asset_generate")); @@ -216,6 +256,242 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { .any(|candidate| candidate == "canvas.asset_generate")); fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + +#[test] +fn new_autonomous_root_contract_resets_all_sixteen_seed_tasks() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + write_autonomous_editor_api_config_for_test(&config_dir, ""); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-autonomous-seed-reset", + "自主构建种子任务重置测试", + ) + .expect("project init"); + + let seed_tasks = new_game_creation_app_seed_tasks(); + assert_eq!( + seed_tasks.len(), + 16, + "seed task contract must remain explicit" + ); + let non_pending_statuses = [ + GameCreationAppTaskStatus::Completed, + GameCreationAppTaskStatus::Running, + GameCreationAppTaskStatus::WaitingForConfirmation, + GameCreationAppTaskStatus::Failed, + ]; + for (index, task) in seed_tasks.iter().enumerate() { + update_manifest_task_status_at( + &root, + &task.id, + non_pending_statuses[index % non_pending_statuses.len()].clone(), + ) + .expect("prepare non-pending seed task status"); + } + assert!(autonomous_seed_task_statuses_for_test(&root) + .values() + .all(|status| status != &GameCreationAppTaskStatus::Pending)); + + let lane_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire Supervisor lane") + .expect("Supervisor lane available"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + "生成一个完整可玩的小游戏", + "autonomous-seed-reset-root-run", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue new autonomous root contract"); + + let reset_statuses = autonomous_seed_task_statuses_for_test(&root); + assert_eq!(reset_statuses.len(), 16); + assert!(reset_statuses + .values() + .all(|status| status == &GameCreationAppTaskStatus::Pending)); + + drop(lane_lock); + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + +#[test] +fn active_autonomous_root_keeps_generic_preview_and_smoke_from_mutating_seed_statuses() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + write_autonomous_editor_api_config_for_test(&config_dir, ""); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-autonomous-generic-bookkeeping", + "自主构建通用预览记账隔离测试", + ) + .expect("project init"); + + let lane_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire Supervisor lane") + .expect("Supervisor lane available"); + let task = "生成一个完整可玩的小游戏"; + let run_id = "autonomous-generic-bookkeeping-root-run"; + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + task, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue autonomous root contract"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "开始自主构建测试", + vec!["保持 DAG 状态由 autonomous scheduler 投影".to_string()], + ) + .expect("start autonomous root runtime without background drain"); + assert_eq!( + runtime.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert!(autonomous_game_build_root_run_active_at(&root)); + + for task in new_game_creation_app_seed_tasks() { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Completed) + .expect("prepare completed autonomous seed task"); + } + let before = autonomous_seed_task_statuses_for_test(&root); + + record_preview_state( + &root, + GameCreationAppPreviewStatus::Running, + Some("http://127.0.0.1:3210/".to_string()), + Some(3210), + ) + .expect("record generic preview state"); + assert_eq!(autonomous_seed_task_statuses_for_test(&root), before); + + record_command_run( + &root, + GameCreationAppCommandRunState { + command_id: "game.static_smoke".to_string(), + status: GameCreationAppCommandRunStatus::Completed, + output: "generic static smoke passed".to_string(), + log_path: ".agent/logs/generic-static-smoke.log".to_string(), + updated_at: unix_timestamp(), + }, + ) + .expect("record generic static smoke run"); + assert_eq!(autonomous_seed_task_statuses_for_test(&root), before); + + let manifest = read_manifest_for_project(&root).expect("read bookkeeping manifest"); + assert_eq!( + manifest.preview.as_ref().map(|preview| &preview.status), + Some(&GameCreationAppPreviewStatus::Running) + ); + assert_eq!( + manifest + .command_runs + .last() + .map(|run| run.command_id.as_str()), + Some("game.static_smoke") + ); + + drop(lane_lock); + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + +#[test] +fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_key() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + write_autonomous_editor_api_config_for_test(&config_dir, ""); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at( + &root, + "project-autonomous-visual-gate", + "自主构建视觉产物门禁测试", + ) + .expect("project init"); + + fs::write( + root.join("memory/project.md"), + "# 项目记忆\n\n文本降级约束。\n", + ) + .expect("write project memory manifest"); + fs::write( + root.join("game/game_design.md"), + "# 游戏设计\n\n文本原型。\n", + ) + .expect("write game design manifest"); + fs::write( + root.join("assets/manifest.art.json"), + br#"{"assets":[],"degradedToText":true}"#, + ) + .expect("write text art manifest"); + for task_id in ["design-foundation", "art-asset-plan"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete text-only visual owner task"); + } + assert!(!root.join("assets/ui-prototype.png").exists()); + assert!(!root.join("assets/art-spritesheet.png").exists()); + let without_key = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + without_key.get("design-foundation"), + Some(&GameCreationAppTaskStatus::Completed) + ); + assert_eq!( + without_key.get("art-asset-plan"), + Some(&GameCreationAppTaskStatus::Completed) + ); + + write_autonomous_editor_api_config_for_test(&config_dir, "editor-runtime-test-key"); + let with_key_missing_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_missing_images.get("design-foundation"), + Some(&GameCreationAppTaskStatus::Pending) + ); + assert_eq!( + with_key_missing_images.get("art-asset-plan"), + Some(&GameCreationAppTaskStatus::Pending) + ); + + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + for task_id in ["design-foundation", "art-asset-plan"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete visual owner task with registered image"); + } + let with_key_and_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_and_images.get("design-foundation"), + Some(&GameCreationAppTaskStatus::Completed) + ); + assert_eq!( + with_key_and_images.get("art-asset-plan"), + Some(&GameCreationAppTaskStatus::Completed) + ); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); } fn captured_native_function_names_for_test(request: &str) -> BTreeSet { @@ -494,7 +770,7 @@ async fn autonomous_game_build_unverified_mutation_immediately_repairs_into_veri let repair_function_names = captured_native_function_names_for_test(&repair_request); assert_eq!( repair_function_names, - ["project.verify", "command.run_limited"] + ["command.run_limited"] .into_iter() .map(|tool| native_runtime_function_name(tool).expect("verification function")) .collect::>() @@ -970,6 +1246,117 @@ fn autonomous_game_build_profile_is_immutable_and_inherited_by_child_runs() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn autonomous_read_only_preview_roles_accept_their_fixed_core_plans() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-read-only-preview-roles", + "自主只读预览职责测试", + ) + .expect("project init"); + let smoke_arguments = serde_json::json!({ + "reason": "执行 preview-readiness 的固定静态自检", + "input": {"commandId": "game.static_smoke"} + }) + .to_string(); + let preview_arguments = serde_json::json!({ + "reason": "执行 preview-playtest 的固定桌面与移动试玩", + "input": {"viewports": ["desktop", "mobile"]} + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-preview-readiness-smoke", + &native_runtime_function_name("command.run_limited") + .expect("limited command function"), + smoke_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-preview-playtest-validate", + &native_runtime_function_name("preview.validate").expect("preview function"), + preview_arguments, + ), + ], + None, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "preview-readiness": {{ + "apiKey": "autonomous-preview-readiness-key", + "baseUrl": {base_url:?}, + "model": "autonomous-preview-readiness-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }}, + "preview-playtest": {{ + "apiKey": "autonomous-preview-playtest-key", + "baseUrl": {base_url:?}, + "model": "autonomous-preview-playtest-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + + let readiness_task = "这是 autonomous-game-build 的只读静态验证任务,不要修改项目文件。固定核心动作是且只能是 command.run_limited(commandId=game.static_smoke)。"; + let readiness_runtime = bind_autonomous_specialist_runtime_for_test( + &root, + "autonomous-preview-readiness-parent", + "preview-readiness", + "autonomous-preview-readiness-child", + readiness_task, + ); + let readiness_plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "preview-readiness", + &readiness_runtime.session_id, + &readiness_runtime.run_id, + readiness_task, + &[], + 0, + 0, + ) + .await + .expect("request preview-readiness plan") + .expect("preview-readiness fixed core plan"); + assert_eq!(readiness_plan.actions.len(), 1); + assert_eq!(readiness_plan.actions[0].tool, "command.run_limited"); + assert_eq!( + readiness_plan.actions[0].input["commandId"], + "game.static_smoke" + ); + + let playtest_task = "这是 autonomous-game-build 的只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate。"; + let playtest_runtime = bind_autonomous_specialist_runtime_for_test( + &root, + "autonomous-preview-playtest-parent", + "preview-playtest", + "autonomous-preview-playtest-child", + playtest_task, + ); + let playtest_plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "preview-playtest", + &playtest_runtime.session_id, + &playtest_runtime.run_id, + playtest_task, + &[], + 0, + 0, + ) + .await + .expect("request preview-playtest plan") + .expect("preview-playtest fixed core plan"); + assert_eq!(playtest_plan.actions.len(), 1); + assert_eq!(playtest_plan.actions[0].tool, "preview.validate"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn autonomous_game_build_repairs_explicit_read_only_loop_into_completed_delivery() { let root = unique_project_path(); @@ -1534,14 +1921,9 @@ async fn autonomous_game_build_repairs_new_revision_after_failed_playtest_with_v .collect::>(); assert_eq!( repair_function_names, - BTreeSet::from([ - native_runtime_function_name("project.verify") - .expect("verify function") - .as_str(), - native_runtime_function_name("command.run_limited") - .expect("limited command function") - .as_str(), - ]) + BTreeSet::from([native_runtime_function_name("command.run_limited") + .expect("limited command function") + .as_str(),]) ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -2334,14 +2716,9 @@ async fn autonomous_game_build_repairs_post_mutation_read_loop_into_verification .collect::>(); assert_eq!( repair_function_names, - BTreeSet::from([ - native_runtime_function_name("project.verify") - .expect("verify function") - .as_str(), - native_runtime_function_name("command.run_limited") - .expect("limited command function") - .as_str(), - ]) + BTreeSet::from([native_runtime_function_name("command.run_limited") + .expect("limited command function") + .as_str(),]) ); assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); @@ -3042,10 +3419,7 @@ async fn autonomous_static_art_asset_verification_does_not_require_node_project( .and_then(serde_json::Value::as_str) .expect("static art repair instruction"); assert!(repair_instruction.contains("game.static_smoke")); - assert!(repair_instruction.contains("preview.validate")); assert!(!repair_instruction.contains("project.verify")); - assert!(!repair_instruction.contains("package.json")); - assert!(!repair_instruction.contains("Node")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index abaab589f..f852192e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -1481,6 +1481,107 @@ async fn background_agent_runtime_tool_action_respects_agent_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn autonomous_design_foundation_denies_indirect_execution_before_side_effects() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-design-foundation-indirect-policy", + "设计基础角色间接执行门禁测试", + ) + .expect("project init"); + let supervisor_run_id = "design-foundation-indirect-parent-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind supervisor autonomous run"); + let run_id = "design-foundation-indirect-run"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + "design-foundation", + run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(supervisor_run_id.to_string()), + delegation_id: Some("design-foundation-indirect-delegation".to_string()), + }), + ) + .expect("bind delegated design-foundation autonomous run"); + + let verify_command = + "node -e \"require('fs').writeFileSync('project-verify-executed.txt','executed')\""; + fs::write( + root.join("package.json"), + serde_json::json!({ + "scripts": { + "check": verify_command, + } + }) + .to_string(), + ) + .expect("write project verify canary script"); + + let project_verify = execute_game_creator_agent_runtime_tool_action( + &root, + "design-foundation", + run_id, + "不得运行项目脚本", + &AgentRuntimeToolAction { + tool: "project.verify".to_string(), + reason: Some("验证角色门禁先于命令执行".to_string()), + input: serde_json::json!({ + "script": "check", + "expectedCommand": verify_command, + "timeoutSeconds": 30, + }), + }, + ) + .await; + let mcp_call = execute_game_creator_agent_runtime_tool_action( + &root, + "design-foundation", + run_id, + "不得调用外部 MCP", + &AgentRuntimeToolAction { + tool: "mcp.call".to_string(), + reason: Some("验证角色门禁先于 MCP 解析和调用".to_string()), + input: serde_json::json!({ + "server": "must-not-run", + "tool": "must-not-run", + "arguments": {}, + }), + }, + ) + .await; + + for observation in [&project_verify, &mcp_call] { + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("design-foundation")); + } + assert!(!root.join("project-verify-executed.txt").exists()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains("agent.runtime.project.verify")); + assert!(!agent_db.contains("agent.runtime.mcp.call")); + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "command.run_limited", + ) + .is_none()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_tool_action_respects_confirm_policy() { let root = unique_project_path(); 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 f3f490fd0..b7ed4d4cc 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 @@ -2983,6 +2983,9 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { #[tokio::test] async fn task_update_requires_registered_visual_asset_before_completion() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), + ); for (task_id, local_path, kind, missing_summary) in [ ( "design-foundation", diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 4ed29e95d..fb76f67b7 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -648,6 +648,7 @@ export function isAgentRuntimeSteerableState(runtime: AgentRuntimeState) { 'waiting-for-confirmation', 'waiting-for-isolated-join', 'waiting-for-delegate-receipts', + 'waiting-for-manifest-tasks', 'response', ].includes(runtime.phase) ); @@ -1173,6 +1174,9 @@ export function projectSupervisorRuntimeStatusLabel( ) { return '等待专业 Agent'; } + if (runtime.phase === 'waiting-for-manifest-tasks') { + return '等待项目任务'; + } if ( ['action', 'observation', 'executing'].includes(runtime.phase) || runtime.status === 'executing' @@ -1299,6 +1303,9 @@ export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { ) { return { label: '协作中', tone: 'running' }; } + if (runtime.phase === 'waiting-for-manifest-tasks') { + return { label: '项目任务中', tone: 'running' }; + } return { label: '执行中', tone: 'running' }; } @@ -1344,6 +1351,9 @@ export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { ) { return '正在等待专业 Agent 回执'; } + if (runtime.phase === 'waiting-for-manifest-tasks') { + return '正在等待项目专业任务完成'; + } if (runtime.status === 'failed' || runtime.phase === 'failed') { return '本轮工作执行失败'; } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index 48e9e6dfa..fb2095acd 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -1111,8 +1111,9 @@ export function ProjectSupervisorRuntimePanel({ ); })} - ) : runtime?.phase === 'waiting-for-delegate-receipts' ? ( - 正在同步专业 Agent 的实时状态… + ) : runtime?.phase === 'waiting-for-delegate-receipts' || + runtime?.phase === 'waiting-for-manifest-tasks' ? ( + 正在同步项目专业任务的实时状态… ) : null} {professionalActionError ? ( diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index 056e51091..24605195a 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import { lstat, mkdir, @@ -12,32 +13,58 @@ import { import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { deflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; +import { + buildGameCreatorWizardConfig, + gameCreatorProviderPresets, + normalizeWizardBaseUrl, + parseConfigWizardArguments, + readGameCreatorWizardConfigState, + resolveGameCreatorAppConfigDir, + writeGameCreatorConfigAtomically, + writeGameCreatorWizardConfig, +} from '../scripts/game-creator-config-wizard.mjs'; import { appIdentifier, buildCargoCliArguments, + buildMissingConfigWizardArguments, + canPromptForMissingRuntimeConfig, + childExitWithTimeout, cleanupSwarmTestProject, cleanupSwarmTestRuntimeConfig, configFileName, + defaultRealSwarmTestTask, defaultRuntimeConfigDirCandidates, discoverRuntimeConfigDir, + hasConfiguredEditorApiKey, hasGeneratedGameEntry, + hasIncompleteArtifactMarker, + inspectSwarmProjectArtifacts, localConfigFileName, parseRunnerShutdownOutput, + parseSettledSwarmTurnReport, parseSwarmTestArguments, prepareSwarmTestProject, prepareSwarmTestRuntimeConfig, + removeDirectoryWithTimeout, + requiredSwarmManifestTaskIds, runnerEndpointFileName, + resolveSwarmTestTimeoutMs, + shouldStartPersistentPreview, testProjectPrefix, testProjectSentinelName, testProjectSentinelSchema, testRuntimeConfigPrefix, testRuntimeConfigSentinelName, testRuntimeConfigSentinelSchema, + terminateChildTree, ungeneratedGameEntryMarker, + validatePngBytes, validatePreviewUrl, + validateSwarmProjectArtifacts, } from '../scripts/agent-swarm-test-chat.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -65,6 +92,545 @@ async function pathExists(targetPath: string): Promise { ); } +const minimumFormalArtifactContents: Record = { + 'memory/project.md': + '# Project\n\n原创项目目标、世界观、资源与单位命名均已确定。\n', + 'game/game_design.md': + '# Game design\n\n玩家放置原创守卫完成波次,胜利后进入下一关,并可随时重新开始。\n', + 'game/balance.json': '{"speed":1,"waves":3}\n', + 'assets/manifest.art.json': + '{"assets":[{"path":"assets/art-spritesheet.png"}]}\n', + 'assets/manifest.audio.json': '{"assets":[],"status":"planned"}\n', + 'game/index.html': + '\n', + 'exports/README.md': + '# Export\n\n项目已完成静态检查与桌面、移动双视口试玩。\n', +}; + +const fixturePngSignature = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function fixtureCrc32(bytes: Buffer): number { + let value = 0xffffffff; + for (const byte of bytes) { + value ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + } + return (value ^ 0xffffffff) >>> 0; +} + +function fixturePngChunk(type: string, data: Buffer): Buffer { + const typeBytes = Buffer.from(type, 'ascii'); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBytes.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE( + fixtureCrc32(Buffer.concat([typeBytes, data])), + 8 + data.length, + ); + return chunk; +} + +function fixturePng( + width: number, + height: number, + { invalidFilter = false, trailingCompressedBytes = false } = {}, +): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 1; + ihdr[9] = 0; + const rowBytes = Math.ceil(width / 8); + const scanlines = Buffer.alloc((rowBytes + 1) * height); + let pseudoRandom = 0x12345678; + for (let row = 0; row < height; row += 1) { + const rowOffset = row * (rowBytes + 1); + scanlines[rowOffset] = invalidFilter && row === 0 ? 5 : 0; + for (let column = 0; column < rowBytes; column += 1) { + pseudoRandom = (Math.imul(pseudoRandom, 1664525) + 1013904223) >>> 0; + scanlines[rowOffset + column + 1] = pseudoRandom >>> 24; + } + } + return Buffer.concat([ + fixturePngSignature, + fixturePngChunk('IHDR', ihdr), + fixturePngChunk( + 'IDAT', + trailingCompressedBytes + ? Buffer.concat([deflateSync(scanlines), Buffer.from('junk')]) + : deflateSync(scanlines), + ), + fixturePngChunk('IEND', Buffer.alloc(0)), + ]); +} + +function fixtureIndexedPng({ + includePalette = true, + duplicatePalette = false, + unknownCriticalChunk = false, +} = {}): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1, 0); + ihdr.writeUInt32BE(1, 4); + ihdr[8] = 8; + ihdr[9] = 3; + const palette = fixturePngChunk('PLTE', Buffer.from([0, 0, 0])); + return Buffer.concat([ + fixturePngSignature, + fixturePngChunk('IHDR', ihdr), + ...(includePalette ? [palette] : []), + ...(duplicatePalette ? [palette] : []), + ...(unknownCriticalChunk ? [fixturePngChunk('ABCD', Buffer.alloc(0))] : []), + fixturePngChunk('IDAT', deflateSync(Buffer.from([0, 0]))), + fixturePngChunk('IEND', Buffer.alloc(0)), + ]); +} + +async function writeMinimumFormalArtifacts(root: string): Promise { + for (const [relativePath, content] of Object.entries( + minimumFormalArtifactContents, + )) { + const targetPath = path.join(root, ...relativePath.split('/')); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content); + } + const revision = 8; + const reportPath = + '.agent/runtime/browser-validations/publish-package/test-run/8/validation.json'; + const desktopPath = + '.agent/runtime/browser-validations/publish-package/test-run/8/desktop.png'; + const mobilePath = + '.agent/runtime/browser-validations/publish-package/test-run/8/mobile.png'; + await mkdir(path.join(root, '.agent', 'runtime'), { recursive: true }); + await writeFile( + path.join(root, '.agent', 'manifest.json'), + `${JSON.stringify({ + tasks: requiredSwarmManifestTaskIds.map((id) => ({ + id, + status: 'completed', + })), + })}\n`, + ); + await writeFile( + path.join(root, '.agent', 'runtime', 'project-revision.json'), + `${JSON.stringify({ revision })}\n`, + ); + const evidenceDirectory = path.dirname(path.join(root, reportPath)); + await mkdir(evidenceDirectory, { recursive: true }); + await writeFile(path.join(root, desktopPath), fixturePng(1280, 720)); + await writeFile(path.join(root, mobilePath), fixturePng(390, 844)); + await writeFile( + path.join(root, reportPath), + `${JSON.stringify({ + passed: true, + playtest: { passed: true }, + viewportResults: [ + { viewport: 'desktop', passed: true }, + { viewport: 'mobile', passed: true }, + ], + })}\n`, + ); + await writeFile( + path.join(root, '.agent', 'agent.db'), + [ + JSON.stringify({ + recordType: 'agent.runtime.command.run_limited', + commandId: 'game.static_smoke', + status: 'completed', + revision, + }), + JSON.stringify({ + recordType: 'agent.runtime.preview.validation', + passed: true, + playtestPassed: true, + revision, + reportPath, + screenshots: [desktopPath, mobilePath], + updatedAt: 1, + }), + '', + ].join('\n'), + ); +} + +async function writeReadyTaskExactlyOnceEvidence( + root: string, + parentRunId: string, +): Promise { + const databasePath = path.join(root, '.agent', 'agent.db'); + const database = await readFile(databasePath, 'utf8'); + const records: Array> = []; + for (const taskId of requiredSwarmManifestTaskIds) { + const runId = `autonomous-ready-${taskId}-fixture`; + const taskDirectory = path.join(root, '.agent', 'runtime', 'tasks'); + await mkdir(taskDirectory, { recursive: true }); + await writeFile( + path.join(taskDirectory, `${taskId}.jsonl`), + `${JSON.stringify({ + agentId: taskId, + taskId, + runId, + source: 'agent-ready-task-scheduler', + runProfile: 'autonomous-game-build', + parentAgentId: 'project-supervisor', + parentRunId, + status: 'completed', + phase: 'completed', + })}\n`, + ); + records.push( + { + recordType: 'agent.runtime.background_task', + agentId: taskId, + taskId, + runId, + source: 'agent-ready-task-scheduler', + }, + { + recordType: 'agent.runtime.background_task.completed', + agentId: taskId, + taskId, + runId, + source: 'agent-ready-task-scheduler', + }, + { + recordType: 'agent.runtime.autonomous_ready_task.manifest_projected', + agentId: taskId, + taskId, + runId, + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId, + terminalPhase: 'completed', + manifestStatus: 'completed', + }, + ); + } + await writeFile( + databasePath, + `${database.trimEnd()}\n${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + ); +} + +describe('terminal configuration wizard arguments', () => { + it('parses the supported options and documented defaults', () => { + const configDir = path.resolve('fixture-config'); + + expect(parseConfigWizardArguments([])).toEqual({ + configDir: null, + configureOnly: false, + help: false, + }); + expect( + parseConfigWizardArguments([ + '--config-dir', + configDir, + '--configure-only', + '--help', + ]), + ).toEqual({ configDir, configureOnly: true, help: true }); + expect(parseConfigWizardArguments(['-h']).help).toBe(true); + }); + + it.each([ + ['separate API key argument', ['--api-key', 'fixture-secret'], 'API Key'], + ['inline API key argument', ['--api-key=fixture-secret'], 'API Key'], + ['relative config directory', ['--config-dir', 'relative'], '绝对路径'], + ['missing config directory', ['--config-dir'], '缺少目录路径'], + [ + 'duplicate config directory', + ['--config-dir', '/first', '--config-dir', '/second'], + '只能指定一次', + ], + ['unknown option', ['--unknown'], '未知选项'], + ])('rejects %s', (_label, args, marker) => { + expect(() => parseConfigWizardArguments(args)).toThrow(marker); + }); +}); + +describe('terminal configuration wizard AppData paths', () => { + it.each([ + { + label: 'Linux XDG config', + context: { + platform: 'linux', + environment: { XDG_CONFIG_HOME: '/fixture/xdg' }, + homeDirectory: '/fixture/home', + }, + candidate: path.posix.join('/fixture/xdg', appIdentifier), + }, + { + label: 'macOS Application Support', + context: { + platform: 'darwin', + environment: {}, + homeDirectory: '/Users/fixture', + }, + candidate: path.posix.join( + '/Users/fixture', + 'Library', + 'Application Support', + appIdentifier, + ), + }, + { + label: 'Windows roaming AppData', + context: { + platform: 'win32', + environment: { + APPDATA: 'C:\\Users\\fixture\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local', + }, + homeDirectory: 'C:\\Users\\fixture', + }, + candidate: path.win32.join( + 'C:\\Users\\fixture\\AppData\\Roaming', + appIdentifier, + ), + }, + ])( + 'resolves the GUI-compatible $label directory', + ({ context, candidate }) => { + expect(resolveGameCreatorAppConfigDir(context)).toBe(candidate); + }, + ); + + it('uses an explicit absolute directory and rejects a relative one', () => { + const explicitConfigDir = path.resolve('explicit-config'); + + expect(resolveGameCreatorAppConfigDir({ explicitConfigDir })).toBe( + explicitConfigDir, + ); + expect(() => + resolveGameCreatorAppConfigDir({ explicitConfigDir: 'relative-config' }), + ).toThrow('绝对路径'); + }); +}); + +describe('terminal configuration wizard providers', () => { + it('publishes the supported provider presets exactly', () => { + expect(gameCreatorProviderPresets).toEqual([ + { + id: 'openai', + label: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + }, + { + id: 'deepseek', + label: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + apiKind: 'openai_chat', + }, + { + id: 'anthropic', + label: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + model: 'claude-3-5-sonnet-latest', + apiKind: 'anthropic', + }, + { + id: 'ark', + label: '火山 Ark', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + model: 'doubao-seed-1-6', + apiKind: 'openai_chat', + }, + { + id: 'custom', + label: '自定义', + baseUrl: '', + model: '', + apiKind: 'openai_chat', + }, + ]); + expect(Object.isFrozen(gameCreatorProviderPresets)).toBe(true); + }); +}); + +describe('terminal configuration wizard config merge', () => { + it('updates the default LLM while retaining per-Agent, Editor API, and MCP config', () => { + const existingConfig = { + schemaVersion: 3, + llm: { + apiKey: 'old-fixture-secret', + model: 'old-model', + maxRetries: 2, + }, + agentLlm: { + 'project-supervisor': { providerId: 'supervisor-provider' }, + }, + editorApi: { + apiKey: 'fixture-editor-secret', + baseUrl: 'https://editor.example.test/v1', + }, + mcpServers: [{ id: 'fixture-mcp', enabled: true }], + }; + const originalConfig = structuredClone(existingConfig); + + const merged = buildGameCreatorWizardConfig(existingConfig, { + apiKey: ' new-fixture-secret ', + baseUrl: 'https://llm.example.test/v1/', + model: ' fixture-model ', + apiKind: 'openai_chat', + }); + + expect(merged).toEqual({ + ...originalConfig, + llm: { + apiKey: 'new-fixture-secret', + model: 'fixture-model', + maxRetries: 2, + baseUrl: 'https://llm.example.test/v1', + apiKind: 'openai_chat', + reasoningEffort: 'high', + }, + }); + expect(merged.agentLlm).toEqual(originalConfig.agentLlm); + expect(merged.editorApi).toEqual(originalConfig.editorApi); + expect(merged.mcpServers).toEqual(originalConfig.mcpServers); + expect(existingConfig).toEqual(originalConfig); + }); + + it('uses provider-default reasoning for Anthropic', () => { + expect( + buildGameCreatorWizardConfig( + { llm: { webSearchEnabled: true } }, + { + apiKey: 'fixture-secret', + baseUrl: 'https://api.anthropic.com/', + model: 'claude-fixture', + apiKind: 'anthropic', + }, + ).llm, + ).toMatchObject({ reasoningEffort: 'default', webSearchEnabled: false }); + }); +}); + +describe('terminal configuration wizard URL safety', () => { + it.each([ + ['https://provider.example.test/v1///', 'https://provider.example.test/v1'], + ['http://127.0.0.1:8080/v1/', 'http://127.0.0.1:8080/v1'], + ['http://localhost:8080/', 'http://localhost:8080'], + ['http://[::1]:8080/v1/', 'http://[::1]:8080/v1'], + ])('accepts %s', (input, expected) => { + expect(normalizeWizardBaseUrl(input)).toBe(expected); + }); + + it.each([ + '', + 'not-a-url', + 'http://provider.example.test/v1', + 'ftp://provider.example.test/v1', + 'https://user:password@provider.example.test/v1', + 'https://provider.example.test/v1?token=fixture', + 'https://provider.example.test/v1#fragment', + ])('rejects unsafe Base URL %s', (input) => { + expect(() => normalizeWizardBaseUrl(input)).toThrowError(); + }); +}); + +describe('terminal configuration wizard persistence', () => { + it('atomically replaces a private AppData config file', async () => { + await withTemporaryRoot(async (root) => { + const configDir = path.join(root, 'nested', appIdentifier); + const configPath = path.join(configDir, configFileName); + const firstConfig = { llm: { model: 'first-fixture-model' } }; + const finalConfig = { + llm: { model: 'final-fixture-model', apiKey: 'fixture-secret' }, + agentLlm: { 'project-supervisor': { model: 'agent-fixture-model' } }, + }; + + await expect( + writeGameCreatorConfigAtomically(configPath, firstConfig), + ).resolves.toBe(configPath); + const firstMetadata = await lstat(configPath); + await expect( + writeGameCreatorConfigAtomically(configPath, finalConfig), + ).resolves.toBe(configPath); + + const [directoryMetadata, finalMetadata, entries, contents] = + await Promise.all([ + lstat(configDir), + lstat(configPath), + readdir(configDir), + readFile(configPath, 'utf8'), + ]); + expect(JSON.parse(contents)).toEqual(finalConfig); + expect(contents.endsWith('\n')).toBe(true); + expect(entries).toEqual([configFileName]); + expect(finalMetadata.isFile()).toBe(true); + expect(finalMetadata.isSymbolicLink()).toBe(false); + if (process.platform !== 'win32') { + expect(directoryMetadata.mode & 0o077).toBe(0); + expect(finalMetadata.mode & 0o077).toBe(0); + expect([finalMetadata.dev, finalMetadata.ino]).not.toEqual([ + firstMetadata.dev, + firstMetadata.ino, + ]); + } + }); + }); + + it('moves the default LLM to primary config and lets later GUI saves win', async () => { + await withTemporaryRoot(async (root) => { + const configDir = path.join(root, appIdentifier); + const primaryPath = path.join(configDir, configFileName); + const localPath = path.join(configDir, localConfigFileName); + await mkdir(configDir); + await writeFile( + primaryPath, + '{"llm":{"model":"primary","requestTimeoutMs":12345},"editorApi":{"apiKey":"canvas"}}\n', + ); + await writeFile( + localPath, + '{"llm":{"model":"stale-local","stream":true},"agentLlm":{"planner":{"model":"planner"}},"mcpServers":{"local":{"command":"fixture"}}}\n', + ); + + const state = await readGameCreatorWizardConfigState(configDir); + expect(state.configPath).toBe(primaryPath); + expect(state.effectiveConfig.llm.model).toBe('stale-local'); + const wizardConfig = buildGameCreatorWizardConfig(state.writeConfig, { + apiKey: 'wizard-key', + baseUrl: 'https://provider.example.test/v1', + model: 'wizard-model', + apiKind: 'openai_chat', + }); + await writeGameCreatorWizardConfig(state, wizardConfig); + + const sanitizedLocal = JSON.parse(await readFile(localPath, 'utf8')); + expect(sanitizedLocal.llm).toBeUndefined(); + expect(sanitizedLocal.agentLlm.planner.model).toBe('planner'); + expect(sanitizedLocal.mcpServers.local.command).toBe('fixture'); + const afterWizard = await readGameCreatorWizardConfigState(configDir); + expect(afterWizard.effectiveConfig.llm).toMatchObject({ + apiKey: 'wizard-key', + model: 'wizard-model', + stream: true, + requestTimeoutMs: 12345, + }); + + const guiConfig = JSON.parse(await readFile(primaryPath, 'utf8')); + guiConfig.llm = { + ...guiConfig.llm, + apiKey: 'gui-key', + model: 'gui-model', + }; + await writeGameCreatorConfigAtomically(primaryPath, guiConfig); + const afterGui = await readGameCreatorWizardConfigState(configDir); + expect(afterGui.effectiveConfig.llm.apiKey).toBe('gui-key'); + expect(afterGui.effectiveConfig.llm.model).toBe('gui-model'); + }); + }); +}); + describe('Swarm test argument parsing', () => { it('returns the documented defaults', () => { expect(parseSwarmTestArguments([])).toEqual({ @@ -72,6 +638,8 @@ describe('Swarm test argument parsing', () => { projectDir: null, keepProject: false, openBrowser: true, + task: null, + timeoutMinutes: null, dryRun: false, help: false, }); @@ -89,6 +657,10 @@ describe('Swarm test argument parsing', () => { projectDir, '--keep-project', '--no-open', + '--task', + '生成一款可试玩的塔防游戏', + '--timeout-minutes', + '75', '--dry-run', '--help', ]), @@ -97,10 +669,38 @@ describe('Swarm test argument parsing', () => { projectDir, keepProject: true, openBrowser: false, + task: '生成一款可试玩的塔防游戏', + timeoutMinutes: 75, dryRun: true, help: true, }); expect(parseSwarmTestArguments(['-h']).help).toBe(true); + expect(() => parseSwarmTestArguments(['--task', ''])).toThrowError(); + }); + + it('keeps persistent preview only for manual chat mode', () => { + expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe( + true, + ); + expect( + shouldStartPersistentPreview( + parseSwarmTestArguments(['--task', '生成一款可试玩的塔防游戏']), + ), + ).toBe(false); + }); + + it('applies a bounded default only to non-interactive tasks', () => { + expect(resolveSwarmTestTimeoutMs(parseSwarmTestArguments([]))).toBeNull(); + expect( + resolveSwarmTestTimeoutMs( + parseSwarmTestArguments(['--task', '生成原创塔防游戏']), + ), + ).toBe(50 * 60_000); + expect( + resolveSwarmTestTimeoutMs( + parseSwarmTestArguments(['--timeout-minutes', '12']), + ), + ).toBe(12 * 60_000); }); it.each([ @@ -124,6 +724,16 @@ describe('Swarm test argument parsing', () => { args: ['--project-dir', '--dry-run'], marker: '--project-dir', }, + { + label: 'duplicate timeout', + args: ['--timeout-minutes', '10', '--timeout-minutes', '20'], + marker: '--timeout-minutes', + }, + { + label: 'invalid timeout', + args: ['--timeout-minutes', '0'], + marker: '1-1440', + }, { label: 'unknown option', args: ['--unsupported'], @@ -193,6 +803,22 @@ describe('runtime config directory candidates', () => { }); describe('runtime config discovery', () => { + it('passes an empty explicit config directory through the TTY wizard only', () => { + const explicitConfigDir = path.resolve('empty-explicit-config'); + const argumentsForWizard = + buildMissingConfigWizardArguments(explicitConfigDir); + + expect(argumentsForWizard.slice(1)).toEqual([ + '--configure-only', + '--config-dir', + explicitConfigDir, + ]); + expect(argumentsForWizard[0]).toMatch(/game-creator-config-wizard\.mjs$/u); + expect(canPromptForMissingRuntimeConfig(true, true)).toBe(true); + expect(canPromptForMissingRuntimeConfig(false, true)).toBe(false); + expect(canPromptForMissingRuntimeConfig(true, false)).toBe(false); + }); + it('discovers an explicitly selected config directory', async () => { await withTemporaryRoot(async (root) => { const configDir = path.join(root, 'explicit-config'); @@ -499,6 +1125,159 @@ describe('cargo CLI argument construction', () => { }); }); +describe('bounded process-tree termination', () => { + it('kills a POSIX process group after its leader exits with stdio still open', async () => { + if (process.platform === 'win32') return; + const child = spawn( + process.execPath, + [ + '-e', + `const { spawn } = require('node:child_process'); +const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'inherit', +}); +grandchild.once('spawn', () => { + process.stdout.write('grandchild-ready\\n'); + setTimeout(() => process.exit(0), 50); +}); +grandchild.once('error', () => process.exit(2));`, + ], + { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + try { + child.stdout.setEncoding('utf8'); + const grandchildReady = new Promise((resolve, reject) => { + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk; + if (output.includes('grandchild-ready')) resolve(); + }); + child.once('error', reject); + }); + child.stderr.resume(); + const leaderExited = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', () => resolve()); + }); + await Promise.all([leaderExited, grandchildReady]); + expect(child.exitCode).toBe(0); + const startedAt = Date.now(); + await expect( + childExitWithTimeout(child, 20, 'fixture process tree', { + graceMs: 50, + forceWaitMs: 500, + }), + ).rejects.toMatchObject({ code: 'AGC_CHILD_TIMEOUT' }); + expect(Date.now() - startedAt).toBeLessThan(2_000); + } finally { + await terminateChildTree(child, 'SIGKILL', true); + } + }); + + it('bounds recursive cleanup in an independently terminable child', async () => { + if (process.platform === 'win32') return; + const target = await mkdtemp( + path.join(os.tmpdir(), 'genarrative-bounded-cleanup-test-'), + ); + try { + await expect( + removeDirectoryWithTimeout(target, { + timeoutMs: 20, + childProgram: 'setInterval(() => {}, 1000);', + }), + ).rejects.toMatchObject({ code: 'AGC_CHILD_TIMEOUT' }); + expect(await pathExists(target)).toBe(true); + } finally { + await rm(target, { recursive: true, force: true }); + } + }); +}); + +describe('non-interactive Swarm turn report validation', () => { + const settledReport = { + schemaVersion: 'game-creator-swarm-turn-report.v1', + outcome: 'settled', + parentAgentId: 'project-supervisor', + sessionId: 'agent-session-project-supervisor', + parentRunId: 'swarm-project-supervisor-fixture', + runtimeCount: 5, + busyRuntimeCount: 0, + pendingTaskCount: 0, + runningTaskCount: 0, + waitingForConfirmationCount: 0, + waitingForUserInputCount: 0, + newAssistantMessageCount: 1, + finalReplyChars: 128, + reconciliationAgentCount: 0, + }; + const outputFor = (...reports: unknown[]) => + [ + '[状态] 正在收束', + ...reports.map((report) => `[turn.report] ${JSON.stringify(report)}`), + '[完成] 本轮结束', + ].join('\n'); + + it('accepts exactly one settled report with no remaining work', () => { + expect(parseSettledSwarmTurnReport(outputFor(settledReport))).toEqual( + settledReport, + ); + }); + + it.each([ + ['missing', '[状态] 没有报告'], + ['duplicate', outputFor(settledReport, settledReport)], + ['malformed JSON', '[turn.report] {invalid'], + ['non-object JSON', '[turn.report] []'], + [ + 'incomplete shape', + outputFor( + (({ finalReplyChars: _removed, ...report }) => report)(settledReport), + ), + ], + ['unknown shape', outputFor({ ...settledReport, unexpected: true })], + ])('rejects a %s report', (_label, output) => { + expect(() => parseSettledSwarmTurnReport(output)).toThrowError(); + }); + + it.each(['failed', 'incomplete', 'needs-reconciliation'])( + 'rejects the %s outcome', + (outcome) => { + expect(() => + parseSettledSwarmTurnReport(outputFor({ ...settledReport, outcome })), + ).toThrow(`outcome=${outcome}`); + }, + ); + + it.each([ + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'reconciliationAgentCount', + ])('rejects non-zero %s', (field) => { + expect(() => + parseSettledSwarmTurnReport(outputFor({ ...settledReport, [field]: 1 })), + ).toThrow(field); + }); + + it.each([ + ['no Runtime', { runtimeCount: 0 }], + ['no assistant reply', { newAssistantMessageCount: 0 }], + ['multiple assistant replies', { newAssistantMessageCount: 2 }], + ['empty final reply', { finalReplyChars: 0 }], + ['implausibly large final reply', { finalReplyChars: 1_000_001 }], + ['fractional count', { pendingTaskCount: 0.5 }], + ['missing parent run', { parentRunId: null }], + ])('rejects %s', (_label, overrides) => { + expect(() => + parseSettledSwarmTurnReport( + outputFor({ ...settledReport, ...overrides }), + ), + ).toThrowError(); + }); +}); + describe('generated game entry validation', () => { it('rejects the initializer placeholder and accepts generated HTML', async () => { await withTemporaryRoot(async (root) => { @@ -519,6 +1298,447 @@ describe('generated game entry validation', () => { }); }); +describe('formal Swarm project artifact validation', () => { + it('reports every missing required artifact by relative path', async () => { + await withTemporaryRoot(async (root) => { + const inspection = await inspectSwarmProjectArtifacts(root); + + expect(inspection.valid).toBe(false); + expect(inspection.invalidPaths).toEqual([ + ...Object.keys(minimumFormalArtifactContents), + '.agent/manifest.json', + '.agent/runtime/project-revision.json', + '.agent/agent.db', + ]); + const validationError = await validateSwarmProjectArtifacts(root).then( + () => null, + (error: Error) => error, + ); + expect(validationError).toBeInstanceOf(Error); + for (const relativePath of Object.keys(minimumFormalArtifactContents)) { + expect(validationError?.message).toContain(relativePath); + } + + await mkdir(path.join(root, 'exports')); + await writeFile(path.join(root, 'exports', 'README.md'), ' \n'); + const emptyInspection = await inspectSwarmProjectArtifacts(root); + expect(emptyInspection.issues).toContainEqual({ + path: 'exports/README.md', + reason: '空文件', + }); + + if (process.platform !== 'win32') { + const target = path.join(root, 'project-target.md'); + await mkdir(path.join(root, 'memory')); + await writeFile(target, '# Outside artifact path\n'); + await symlink(target, path.join(root, 'memory', 'project.md')); + const symlinkInspection = await inspectSwarmProjectArtifacts(root); + expect(symlinkInspection.issues).toContainEqual({ + path: 'memory/project.md', + reason: '不是无符号链接普通文件', + }); + } + }); + }); + + it('rejects each malformed JSON artifact', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const invalidJsonPaths = [ + 'game/balance.json', + 'assets/manifest.art.json', + 'assets/manifest.audio.json', + ]; + await Promise.all( + invalidJsonPaths.map((relativePath) => + writeFile(path.join(root, ...relativePath.split('/')), '{invalid'), + ), + ); + + const inspection = await inspectSwarmProjectArtifacts(root); + + expect(inspection.invalidPaths).toEqual(invalidJsonPaths); + expect(inspection.issues).toEqual( + invalidJsonPaths.map((relativePath) => ({ + path: relativePath, + reason: 'JSON 无法解析', + })), + ); + }); + }); + + it('rejects placeholder Markdown and empty JSON objects', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const projectMemoryPath = path.join(root, 'memory', 'project.md'); + await writeFile(projectMemoryPath, '# TODO\n\n待补充项目说明。\n'); + let inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: 'memory/project.md', + reason: '仍包含占位标记', + }); + + await writeFile( + projectMemoryPath, + minimumFormalArtifactContents['memory/project.md'], + ); + const gameEntryPath = path.join(root, 'game', 'index.html'); + await writeFile( + gameEntryPath, + '\n', + ); + inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: 'game/index.html', + reason: '仍包含占位标记', + }); + + await writeFile( + gameEntryPath, + minimumFormalArtifactContents['game/index.html'], + ); + await writeFile(path.join(root, 'game', 'balance.json'), '{}\n'); + inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: 'game/balance.json', + reason: 'JSON 必须是非空对象', + }); + }); + }); + + it.each([ + ['TODO', 'TODO: 补齐导出说明'], + ['TBD', 'TBD - export notes'], + ['placeholder', 'This is a placeholder document.'], + ['coming soon', 'Release notes are coming soon.'], + ['lorem ipsum', 'Lorem ipsum dolor sit amet.'], + ['待补充', '导出说明待补充。'], + ['待完善', '移动端结论待完善。'], + ['占位', '本文件仅供流程占位。'], + ['尚未完成', '最终试玩尚未完成。'], + ['稍后补充', '截图说明稍后补充。'], + ['待填写', '版本信息待填写。'], + ['待验证', '桌面视口待验证。'], + ['待复核', '最终结论待复核。'], + ['待确认', '发布范围待确认。'], + ['待定', '交付日期待定。'], + ['unchecked checklist', '- [ ] 补齐移动端试玩记录'], + ])('rejects exports/README.md containing %s', async (_label, marker) => { + expect(hasIncompleteArtifactMarker(`# Export\n\n${marker}\n`)).toBe(true); + + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + await writeFile( + path.join(root, 'exports', 'README.md'), + `# Export\n\n当前导出流程记录如下。\n\n${marker}\n`, + ); + + const inspection = await inspectSwarmProjectArtifacts(root); + + expect(inspection.issues).toContainEqual({ + path: 'exports/README.md', + reason: '仍包含占位标记', + }); + }); + }); + + it('accepts complete formal Markdown and checked task lists', async () => { + const completeReadme = [ + '# Export', + '', + '- [x] 桌面视口试玩验证已经通过', + '- [X] 移动视口试玩验证已经通过', + '', + '版本信息已经填写,静态检查、人工复核和发布范围确认均已完成。', + '', + ].join('\n'); + expect(hasIncompleteArtifactMarker(completeReadme)).toBe(false); + + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + await writeFile(path.join(root, 'exports', 'README.md'), completeReadme); + + await expect(validateSwarmProjectArtifacts(root)).resolves.toMatchObject({ + valid: true, + }); + }); + }); + + it('requires all 16 manifest tasks and browser evidence for the current revision', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const manifestPath = path.join(root, '.agent', 'manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.tasks[0].status = 'running'; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + + let inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: '.agent/manifest.json', + reason: '固定 16 个正式任务未全部且仅完成一次', + }); + + manifest.tasks[0].status = 'completed'; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + await writeFile( + path.join(root, '.agent', 'runtime', 'project-revision.json'), + '{"revision":9}\n', + ); + inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: '.agent/agent.db', + reason: '缺少当前 revision 的桌面与移动试玩通过凭证', + }); + }); + }); + + it('binds all 16 task lifecycles exactly once to the settled parent run', async () => { + await withTemporaryRoot(async (root) => { + const parentRunId = 'swarm-project-supervisor-exactly-once'; + await writeMinimumFormalArtifacts(root); + await writeReadyTaskExactlyOnceEvidence(root, parentRunId); + + await expect( + validateSwarmProjectArtifacts(root, { parentRunId }), + ).resolves.toMatchObject({ valid: true }); + + const databasePath = path.join(root, '.agent', 'agent.db'); + const baselineDatabase = await readFile(databasePath, 'utf8'); + const failedThenCompletedTaskId = requiredSwarmManifestTaskIds[0]; + await writeFile( + databasePath, + `${baselineDatabase}${JSON.stringify({ + recordType: 'agent.runtime.background_task.failed', + agentId: failedThenCompletedTaskId, + taskId: failedThenCompletedTaskId, + runId: `autonomous-ready-${failedThenCompletedTaskId}-fixture`, + source: 'agent-ready-task-scheduler', + })}\n`, + ); + const failedThenCompleted = await inspectSwarmProjectArtifacts(root, { + parentRunId, + }); + expect(failedThenCompleted.issues).toContainEqual({ + path: `.agent/runtime/tasks/${failedThenCompletedTaskId}.jsonl`, + reason: `正式任务 ${failedThenCompletedTaskId} 未在当前父 Run 中恰好启动并完成一次`, + }); + await writeFile(databasePath, baselineDatabase); + + const duplicateTaskId = requiredSwarmManifestTaskIds[0]; + await writeFile( + databasePath, + `${baselineDatabase}${JSON.stringify({ + recordType: 'agent.runtime.background_task', + agentId: duplicateTaskId, + taskId: duplicateTaskId, + runId: `autonomous-ready-${duplicateTaskId}-fixture`, + source: 'agent-ready-task-scheduler', + })}\n`, + ); + const duplicate = await inspectSwarmProjectArtifacts(root, { + parentRunId, + }); + expect(duplicate.issues).toContainEqual({ + path: `.agent/runtime/tasks/${duplicateTaskId}.jsonl`, + reason: `正式任务 ${duplicateTaskId} 未在当前父 Run 中恰好启动并完成一次`, + }); + + const secondRunTaskId = requiredSwarmManifestTaskIds[1]; + const journalPath = path.join( + root, + '.agent', + 'runtime', + 'tasks', + `${secondRunTaskId}.jsonl`, + ); + await writeFile( + journalPath, + `${await readFile(journalPath, 'utf8')}${JSON.stringify({ + agentId: secondRunTaskId, + taskId: secondRunTaskId, + runId: `autonomous-ready-${secondRunTaskId}-second-attempt`, + source: 'agent-ready-task-scheduler', + runProfile: 'autonomous-game-build', + parentAgentId: 'project-supervisor', + parentRunId, + status: 'completed', + phase: 'completed', + })}\n`, + ); + const secondRun = await inspectSwarmProjectArtifacts(root, { + parentRunId, + }); + expect(secondRun.invalidPaths).toContain( + `.agent/runtime/tasks/${secondRunTaskId}.jsonl`, + ); + }); + }); + + it('rejects a stale static smoke record from an older revision', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const databasePath = path.join(root, '.agent', 'agent.db'); + const records = (await readFile(databasePath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + records[0].revision = 7; + await writeFile( + databasePath, + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + ); + + const inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: '.agent/agent.db', + reason: '缺少当前 revision 的静态检查通过凭证', + }); + }); + }); + + it('requires valid image files only when the editor API key is configured', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const configDir = path.join(root, 'config'); + await mkdir(configDir); + await writeFile( + path.join(configDir, configFileName), + '{"editorApi":{"apiKey":" "}}\n', + ); + expect(await hasConfiguredEditorApiKey(configDir)).toBe(false); + await expect(validateSwarmProjectArtifacts(root)).resolves.toMatchObject({ + valid: true, + requireEditorImages: false, + }); + + await writeFile( + path.join(configDir, localConfigFileName), + '{"editorApi":{"apiKey":"fixture-editor-key"}}\n', + ); + expect(await hasConfiguredEditorApiKey(configDir)).toBe(true); + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).rejects.toThrow('assets/ui-prototype.png'); + + await writeFile( + path.join(root, 'assets', 'ui-prototype.png'), + Buffer.from('not-an-image'), + ); + await writeFile( + path.join(root, 'assets', 'art-spritesheet.png'), + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + ); + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).rejects.toThrow('assets/ui-prototype.png'); + + await writeFile( + path.join(root, 'assets', 'ui-prototype.png'), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).rejects.toThrow('assets/ui-prototype.png'); + + const headerOnlyPng = Buffer.alloc(1_024); + fixturePngSignature.copy(headerOnlyPng); + headerOnlyPng.writeUInt32BE(13, 8); + headerOnlyPng.write('IHDR', 12, 'ascii'); + headerOnlyPng.writeUInt32BE(1_600, 16); + headerOnlyPng.writeUInt32BE(900, 20); + await writeFile( + path.join(root, 'assets', 'ui-prototype.png'), + headerOnlyPng, + ); + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).rejects.toThrow('assets/ui-prototype.png'); + + await writeFile( + path.join(root, 'assets', 'ui-prototype.png'), + fixturePng(1_600, 900), + ); + await writeFile( + path.join(root, 'assets', 'art-spritesheet.png'), + fixturePng(1_024, 1_024), + ); + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).resolves.toMatchObject({ valid: true, requireEditorImages: true }); + }); + }); + + it('validates chunk CRC, zlib scanlines, filters, and screenshot PNGs', async () => { + const validPng = fixturePng(1_600, 900); + expect(validatePngBytes(validPng)).toEqual({ width: 1_600, height: 900 }); + + const crcCorrupted = Buffer.from(validPng); + crcCorrupted[42] ^= 0xff; + expect(() => validatePngBytes(crcCorrupted)).toThrow('CRC'); + expect(() => validatePngBytes(validPng.subarray(0, -1))).toThrowError(); + expect(() => + validatePngBytes(fixturePng(320, 180, { invalidFilter: true })), + ).toThrow('filter byte'); + expect(() => + validatePngBytes(fixturePng(320, 180, { trailingCompressedBytes: true })), + ).toThrow('zlib'); + expect(() => + validatePngBytes(fixtureIndexedPng({ includePalette: false })), + ).toThrow('PLTE'); + expect(() => + validatePngBytes(fixtureIndexedPng({ duplicatePalette: true })), + ).toThrow('PLTE'); + expect(() => + validatePngBytes(fixtureIndexedPng({ unknownCriticalChunk: true })), + ).toThrow('critical chunk'); + expect(validatePngBytes(fixtureIndexedPng())).toEqual({ + width: 1, + height: 1, + }); + + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + const desktopScreenshot = path.join( + root, + '.agent', + 'runtime', + 'browser-validations', + 'publish-package', + 'test-run', + '8', + 'desktop.png', + ); + const fakeScreenshot = Buffer.alloc(2_048); + fixturePngSignature.copy(fakeScreenshot); + await writeFile(desktopScreenshot, fakeScreenshot); + + const inspection = await inspectSwarmProjectArtifacts(root); + expect(inspection.issues).toContainEqual({ + path: '.agent/agent.db', + reason: '缺少 desktop 试玩截图', + }); + }); + }); + + it('rejects non-PNG bytes at the fixed PNG artifact paths', async () => { + await withTemporaryRoot(async (root) => { + await writeMinimumFormalArtifacts(root); + await writeFile( + path.join(root, 'assets', 'ui-prototype.png'), + Buffer.from('RIFF\x04\x00\x00\x00WEBP', 'binary'), + ); + await writeFile( + path.join(root, 'assets', 'art-spritesheet.png'), + Buffer.from('GIF89a', 'ascii'), + ); + + await expect( + validateSwarmProjectArtifacts(root, { requireEditorImages: true }), + ).rejects.toThrow('assets/ui-prototype.png'); + }); + }); +}); + describe('preview URL validation', () => { it('accepts an HTTP URL on the numeric loopback host', () => { const previewUrl = 'http://127.0.0.1:4173/'; @@ -545,24 +1765,53 @@ describe('preview URL validation', () => { }); describe('package script registration', () => { - it('registers the root and app test commands exactly', async () => { - const [rootPackage, appPackage] = await Promise.all( + it('registers the root and app config and test commands exactly', async () => { + const [rootPackage, appPackage, checkConfigSource] = await Promise.all( [ new URL('../../../package.json', import.meta.url), new URL('../package.json', import.meta.url), + new URL('../scripts/check-config.mjs', import.meta.url), ].map(async (packageUrl) => - JSON.parse(await readFile(packageUrl, 'utf8')), + packageUrl.pathname.endsWith('.json') + ? JSON.parse(await readFile(packageUrl, 'utf8')) + : readFile(packageUrl, 'utf8'), ), ); + expect(rootPackage.scripts?.['agc:config']).toBe( + 'npm --prefix apps/ai-game-creator-shell run config --', + ); expect(rootPackage.scripts?.['agc:test']).toBe( 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --', ); expect(rootPackage.scripts?.['agc:test:chat']).toBe( 'npm --prefix apps/ai-game-creator-shell run test:chat --', ); + expect(rootPackage.scripts?.['agc:test:chat:manual']).toBe( + 'npm --prefix apps/ai-game-creator-shell run test:chat:manual --', + ); expect(appPackage.scripts?.['test:chat']).toBe( + `node scripts/agent-swarm-test-chat.mjs --task "${defaultRealSwarmTestTask}" --no-open`, + ); + expect(appPackage.scripts?.config).toBe( + 'node scripts/game-creator-config-wizard.mjs', + ); + expect(appPackage.scripts?.['test:chat:manual']).toBe( 'node scripts/agent-swarm-test-chat.mjs', ); + expect(checkConfigSource).toMatch( + /packageConfig\.scripts\?\.config\s*!==\s*'node scripts\/game-creator-config-wizard\.mjs'/u, + ); + expect(checkConfigSource).toMatch( + /rootPackageConfig\.scripts\?\.\['agc:config'\]\s*!==\s*'npm --prefix apps\/ai-game-creator-shell run config --'/u, + ); + const wrapperSource = await readFile( + new URL('../scripts/run-cli-with-config.mjs', import.meta.url), + 'utf8', + ); + expect(wrapperSource).toContain('resolveGameCreatorAppConfigDir'); + expect(wrapperSource).toContain( + "'--config-dir', resolveGameCreatorAppConfigDir()", + ); }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b4fbf6a62..f459e1609 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -113,6 +113,7 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/repository_context.rs`、Agent planning prompt、pending repository-context drift 门禁、真实 Runtime E2E harness 和 AI 游戏创作 Runtime 文档。 - 验证方式:确定性测试覆盖发现根、优先级、YAML/路径/符号链接/预算、metadata 清洗、正文按需可见和 fingerprint 漂移;正式 `openai_chat / gpt-5.5` 的 `project-skill` suite 必须证明 hash-only fixture 先失败、匹配 Skill 在首个变更前读取、无关 Skill 不读取、唯一目标文件修改、Agent 与宿主验证通过,以及 Provider lifecycle、唯一回复、配置隔离和零泄漏全部闭合。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-07-18 图片生成 K 档由 provider 直接生成 - 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。 @@ -239,6 +240,7 @@ - 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。 - 验证方式:运行 `cargo test -p api-server editor_bgfilter_retries_once_before_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-16 SpacetimeDB 备份采用逐文件基线、CAS 增量与安全历史清理 - 背景:SpacetimeDB standalone 2.6.0 不自动删除已被 snapshot 覆盖的历史 commitlog 与旧 snapshot;反复压缩整个 `/stdb` 会重复占用磁盘、停机和 OSS 带宽。上游 issue #5542 的 contributor 明确说明,不触碰最新 snapshot 与重启所需 commitlog suffix 时,可在运行中移动或删除这些历史文件。 @@ -5132,6 +5134,7 @@ - 所有权与清理:账本保留同一 run 的已成功 planning entry,直到 run 完成、取消、失败、作废或明确 reconciliation 清理;这样单动作、多动作、confirmation、协作 batch 和直接回复都不会在下一 durable owner 建立前丢失。steer/cancel/终态/漂移清理前必须按整本账本补齐所有实际 requestId lifecycle,任一条失败时保留账本并进入 reconciliation。Runner 恢复会严格扫描 hash 路径、primary/`.previous` 和安全原子临时文件,清理合法终态遗留;Unix 全程使用固定目录句柄和根目录/Agent 目录 `flock`,安装用 `RENAME_EXCHANGE` 复核回滚,删除用 `RENAME_NOREPLACE` quarantine、inode 复核和原 fd 清空同步;Windows 使用相对父句柄及 `GetFileInformationByHandleEx` 句柄枚举,拒绝 reparse point/junction/硬链接并以禁止共享的独占句柄表示活跃 temp。两端都不依赖 PID 存活判断。未知、链接、目录身份替换或内容冲突项失败关闭。primary、`.previous` 或损坏账本阻止 `runner.shutdown_if_idle`。非协作同 UID 进程可主动忽略 Unix advisory lock,属于宿主 OS 信任边界,不纳入完整沙箱承诺。 - 验收边界:确定性测试必须分别覆盖 base handoff 与 repair handoff 在 lifecycle completed 前停止,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream和终局零 sidecar。独立非默认真实 suite `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级注册;它只使用 sentinel-owned sibling AppData 与 metadata-only zero-fault proxy,每轮随机 capability 严格绑定 project/Agent/run/实际 request slot。断点只能在 handoff 原子落盘并回读一致后、同一实际 requestId lifecycle `completed` 前 ACK,ACK 后才通过 pidfd `SIGKILL` 强杀 suite 自有 Runner。恢复必须在同一轮证明同一 requestId 唯一闭合、`networkReplayCount=0`、protocol/repair audit 幂等、handoff 与 durable batch plan fingerprint 对应、恢复消费前 action/pending/delivery/claim 等副作用为 `0`,并在终局得到零 sidecar、零重复、零临时资源残留和零正文/凭据/URL/绝对路径泄漏。2026-07-20 的真实单轮已经到达并通过上述 checkpoint,但随后因专业 Agent 连续连接失败而整轮 FAIL;另一独立轮因首批工具数不满足 fixture 也未通过,不能拼接为 PASS。Provider 成功到 handoff 原子落盘回读前的 unknown-result 及手动 context-compaction 仍不在本决策承诺内。 - 当前证据:`tool_plan_`、`tool_plan_handoff_`、`provider_handoff_`、`provider_retry_`、`response_stream_`、`finalization_` 与 `finalization_resume_` 定向门禁均保持通过;本轮 `tool_plan_handoff_` 为 `44/44`,Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`。Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`,Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过;E2E self-test、typecheck、变更脚本 ESLint、encoding 和 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。实现过程中发现并修复 thinking 归一化、源码路径误判、repair 漂移删账本、durable control 清理遗漏后继 repair lifecycle、复数敏感 key/Provider ID 泄漏、malformed JSON trivia 路径绕过、Agent DB 审计字段扩张、PID 复用 temp 误判、中间目录/文件名称换绑 TOCTOU、Windows 路径枚举 ABA 和审计尾部近似去重问题。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。 + ## 2026-07-17 旧创作模板与入口停止维护 - 决策:旧创作模板、旧创作入口及其专属运行服务进入下线范围,不再为跳一跳、抓大鹅 Match3D、儿童动作 Demo 等旧链路修复兼容问题、补生成脚本或维持专属门禁。 @@ -5302,3 +5305,36 @@ - 复用边界:已有规范素材只有同时满足固定路径、`art-spritesheet / image/* / canvas` 登记、本地普通文件存在且 PNG 签名有效时才跳过生成;空文件、伪 PNG 或损坏占位必须重新进入美术委派。 - 恢复边界:首批 policy snapshot、durable provider batch 与恢复校验继续绑定同一 required Agent 集合;格式修复必须根据当前 policy 补齐可选的 `art-asset-plan` 固定产物合同,不能只修复程序与质量委派后绕过美术交付。 - 2026-07-25 决策:Swarm CLI 的 `busy` 仅表示当前 Agent 仍有运行、等待、reconciliation 或排队工作,不能作为 steer 目标判定。steer 能力统一复用 Runtime 协议层门禁;terminal canonical run 即使汇总出 pending queue 也永远不可 steer。CLI 发现旧 completed/cancelled run 后仍有 pending run 时先通知独立 Runner 恢复;旧 cancelled run 的 tombstone 不能让恢复扫描跳过后续 pending。新 turn 以 mutation 返回的 `acceptedRunId` 为权威 baseline,失败、交互、收束和 `turn.report.parentRunId` 都只归属该 run;canonical 已推进到下一 run 时从 append-only task journal 恢复目标 run 终态。相同且已落盘的最后一条用户消息只恢复观察原 run,不能重复写 conversation、steer ledger 或 task ledger。active Goal 也必须精确匹配当前 Runtime 身份与可 steer 状态,不能只依据 Goal 的 `active` 字符串直接追加。连续 run 的 assistant 回复按 `agentId + sessionId + runId` 派生的 finalization message ID 归属,失败终态按 `(agentId, runId)` 聚合且完整 task journal 优先于可能滞后的 state 投影;`turn.report` 的运行和队列计数同样读取完整 journal 并仅统计目标 parent run 及其直接 children。 + +## 2026-07-25 autonomous-game-build 升级为正式项目产物 DAG + +- 背景:当前自主构建的终局合同主要要求 `code-prototype`、只读 `quality-review`、可选美术回执和可玩 `game/index.html`;`agc:test:chat` 也主要以该入口文件收束。这只能证明可玩原型,无法证明策划、数值、美术清单、音频需求、项目记忆和发布包装已形成正式产物。 +- 决策:保留单波最多 3 个并行职责的现有资源边界,但将 `autonomous-game-build` 改为由 seed manifest 驱动的分波 DAG:`design-foundation` 先做设计打底;`balance-seed / art-asset-plan / audio-asset-plan` 在依赖就绪后并行;`code-prototype` 再整合上游产物;其后顺序执行 `quality-review`、当前 revision 的静态检查和真实试玩;最后由 `publish-package` 生成发布包装。下游 task 不得在上游合同完成前提前投影为 `completed`。 +- 正式产物合同:无画布 Key 时最小必需路径为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`;美术清单必须明确记录素材尚未生成。配置 Key 时再额外强制 `assets/ui-prototype.png` 和 `assets/art-spritesheet.png`,两张图片必须由受控画布链路生成、真实可读并完成 manifest 登记。Key 已配置但生成无效或失败时不得完成,任何路径都不得使用占位图或伪造登记。`assets/manifest.audio.json` 只是 BGM/SFX 需求清单,不代表真实音频文件。 +- 收束门禁:Project Supervisor 必须同时看到本轮必需 seed manifest tasks 全部 `completed`、当前配置对应的 7 项或 9 项正式产物齐全并通过可解析性/类型验收,以及当前最新 revision 的 `game.static_smoke + preview.validate` 通过,才能收束父 run。子 Agent delivery 完成或 evidence-ready 只是待 Supervisor 语义验收的证据,不能单独放行最终回复。严格 `agc:test:chat` 必须精确核对固定 16 个 manifest task 的唯一 ID 与 `completed` 终态、同一父 Run 下各任务唯一 logical run / 一次 started / 一次 completed / 零 failed-cancelled / 一次 manifest projection、正式路径、配置画布 Key 时的真实 PNG,以及绑定当前 project revision 的静态检查和桌面 / 移动浏览器 playtest;历史 revision 成功、文件仅存在或 PNG magic 命中都不能放行。当前脚本已同时绑定 current revision 的 static smoke 与浏览器证据,并对 PNG 执行 CRC、zlib、scanline、PLTE 和未知 critical chunk 校验。 +- 调度与恢复边界:新根 run 重置全部 16 个 seed task;DAG 只在 `agent.run_status` claim 被可靠观察且静态屏障清空后启动,普通 preview / smoke bookkeeping 不修改自主 DAG。已 `ready / claimed-by-parent` 的相同终态 delivery 恢复重放保持幂等,保留首次冻结结果,不再写重复 `result_failed`。 +- 验证状态:确定性 `npm run agc:test` 已通过,16 个 manifest task exactly-once,父子 run 全部完成,最终 revision 为 `11`,基础正式产物、两张画布 PNG、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏均为 `0`。这是 loopback Provider 下的 Runtime / 文件 / 浏览器证据,独立外部 Provider 仍需单独验收。 + +## 2026-07-25 GUI 与终端共用 AppData 配置并区分自动测试和手工聊天 + +- 配置事实源:GUI“配置”面板与 `npm run agc:config` 统一读写 Tauri identifier `world.genarrative.ai-game-creator` 对应系统 AppData 中的 `game-creator.config.json`,不建立 CLI 专用配置或 `.env` 回退。终端向导提供 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider 预设,采集 Base URL、模型及隐藏输入的 API Key;更新 LLM 配置时必须保留 `agentLlm`、`editorApi`、`mcpServers` 等现有节点,保存后复用 `llm-status` 检查。 +- 密钥边界:所有终端入口禁止 `--api-key` 参数,避免凭据进入 shell history、进程列表和任务日志。API Key 只能通过隐藏交互输入写入 AppData;隐藏输入临时调用 `stdin.resume()` 后必须在成功、取消、stdin 异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复原 raw mode,并在原本 paused 时执行 `stdin.pause()`,信号路径恢复后重发原信号。显式 `--config-dir` 必须是以 `world.genarrative.ai-game-creator` 命名的独立 AppData 叶目录,不能对 `/tmp`、AppData 根或其它共享目录整体执行 `0700` / 私有 DACL。POSIX 下目录权限保持 `0700`、文件权限保持 `0600`,使用同目录临时文件原子替换;Windows AppData 与隔离测试目录必须在写入或复制任何密钥字节前先建立仅当前用户可访问且禁止继承的私有 DACL,随后再写文件并复核 ACL。任何状态、错误或报告都不得回显密钥。 +- 首次运行:`agc:test:chat` 自动发现配置失败时,只在 stdin / stdout 都是 TTY 的人工会话中询问是否启动 `agc:config` 向导;无 TTY、自动化和 CI 必须非零失败并给出确定命令,不得等待交互、静默生成配置或退回仓库模板。向导保存并通过配置检查后可以继续当前真实测试。 +- 模式边界:`agc:test:chat` 默认提交固定植物塔防需求,作为单轮非交互真实测试,不依赖 stdin 或 EOF。它只有在本轮必需 seed task 全部完成、正式产物合同满足、最新 revision 静态检查和 Runtime 浏览器验收通过后才能成功退出;成功后关闭空闲隔离 Runner,并依保留参数清理 sentinel 测试 AppData 与项目,不进入长期 preview。 +- 手工聊天:`agc:test:chat:manual` 不注入 `--task`,进入多轮 stdin 聊天,并在本轮收束后保留持续 localhost preview 供人工试玩,直到用户显式退出。自动测试和手工聊天共用相同 Provider、Runtime、隔离 AppData 以及产物 / current revision 检查;16-task journal exactly-once 的单轮硬验收只属于自动入口,因为手工模式没有同一份自动父 Run `turn.report` 生命周期。 +- 超时与退出:自动真实测试必须有硬截止时间。POSIX 以独立进程组终止整棵 Cargo / CLI / Runner 子进程树,Windows 使用 `taskkill /T`;首次终止后只有短暂宽限期,随后强制终止,并给 Runner shutdown 与清理步骤各自设置有界期限。只向直接 Cargo PID 发送一次信号、等待无界 `close`、或在进入清理前撤销唯一计时器都不构成硬超时;超时和信号退出必须保留非零退出码与无法安全清理的现场。 +- 真实外部验收状态:2026-07-27 新起的一轮独立外部 Provider + External Editor API E2E 使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整收束。该单轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 全部满足当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础产物与两张图片均通过正式产物校验,当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告和 PNG 截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续保留为历史失败,不与本轮拼接;当前这套“16 任务正式产物 + 两张真实画布图片”外部验收状态据此更新为 **PASS**。 + +## 2026-07-26 固定画布产物返工与 design-foundation 职责隔离 + +- 固定输出合同:`design-foundation` 的规范界面图固定为 `assets/ui-prototype.png / 16:9 / 2K / ui-prototype`,`art-asset-plan` 的首版美术图固定为 `assets/art-spritesheet.png / 1:1 / 1K / art-spritesheet`。普通生成始终 `replaceExisting=false`,已有有效登记时复用,不得删除后重生、改路径、改规格或重复扣费。 +- 覆盖授权:`replaceExisting=true` 只允许来自 Project Supervisor 建立的唯一静态 repair delivery;当前 run 必须绑定带 `repairOfDelegationId` 的静态专业 Agent,原 delivery 已由同一父 Agent / 父 run 认领,目标 Agent 与固定 `expectedArtifacts` 逐项一致。普通首轮、动态 child、Supervisor 直接动作、未认领原回执、返工的再次返工或不在原合同内的路径一律失败关闭。 +- stale 防护:发起外部生成前冻结待替换固定路径与原文件 SHA-256;下载完成并取得项目写锁后,提交前重新解析相同路径并复算 fingerprint。路径、文件内容或 fingerprint 在请求期间发生变化时拒绝覆盖,保留并发产生的当前文件;不能因远端生成已经计费或成功就用过期结果覆盖新 revision。 +- `design-foundation` 边界:该 Agent 只拥有 `memory/project.md`、`game/game_design.md`,以及配置画布 Key 时固定的 `assets/ui-prototype.png`。Runtime 文件写入 / patchset / delete 门禁必须阻止其修改 `game/index.html` 或其它程序、发布、音频和美术文件;它不得调用 `game.static_smoke`、`preview.start`、`preview.validate`、进程工具、整项目恢复或自行进行桌面 / 移动试玩。只有 `preview-readiness` 可额外执行固定 `game.static_smoke`,只有 `preview-playtest` 可额外执行 `preview.validate`;预览与 playtest 不能仅依赖 prompt 自律。 + +## 2026-07-26 完成基线、画布审计与并发补验失败关闭 + +- 完成合同:自主根 Run 使用 `game-creator-autonomous-completion-contract.v2`;`baselineArtifacts` 是必填、排序稳定且参与 `contractFingerprint` 的不可变基线。旧 v1、缺少基线、基线条目不安全或提交前基线身份变化都失败关闭,不能复用历史产物冒充本轮完成。 +- 并发补验:确定性 Provider 只在 Runtime 明确返回 revision blocker、专业 verification-only repair、成功验证 observation,或项目锁 / repository context drift 这两类可恢复 observation 时重放终态;每个 logical run 最多 16 次。只读职责不得借补验调用未授权命令,验证失败或缺少 `ok` observation 不能交付,Provider completion 计数始终 exactly-once。 +- 画布审计:资源 manifest 可以保留生成 prompt 作为本地来源元数据,但公开 `asset.register / asset.update` 审计记录必须移除 `source.prompt`,只保留 canvas/resource/task/model 等身份字段,避免完整生成正文进入公开 Agent DB 表面。 +- 当前测试事实:已有回归覆盖固定画布合同不允许被模型改写、已登记 spritesheet 禁止先删除、只有静态 repair 可原位替换、替换期间原文件 fingerprint 漂移时拒绝覆盖,以及 `design-foundation` 对 `game/index.html` 的 write / patchset / delete 和预览工具均被 Runtime 策略阻断。2026-07-27 的独立 75 分钟上限外部真实 E2E 已按上一节单轮证据完整 **PASS**;后续合同变化仍须新起独立轮次,不能复用这次结果替代未来验收。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 72b4f35c3..a678589e5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -85,6 +85,7 @@ - 处理:所有分钟级阶段等待都要在每轮先检查 owning task 的 failed/cancelled/budget-exhausted,以及 Runtime 的 needs-reconciliation;命中后立即抛出带阶段前缀的结构化错误。正常 pause 必须保留为可恢复状态,不能被 fail-fast 当失败;Runner 强杀后的 paused 稳定窗口继续按签名零推进单独验证。 - 验证:用正式 `goal-runtime` 观察 Provider repair transport failure,确认部分报告立即保留成功/repair 协议计数、生命周期闭合和零泄漏证据;随后完整复跑仍能通过 Goal edit/pause/Runner kill/resume/finalization,证明 fail-fast 未破坏正常恢复路径。 - 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + ## 图片生成的 K 档不能靠回图后缩放实现 - 现象:用户选择 2K 时占位框看起来是 2K,最终资源元数据也显示为 2K,但模型请求实际仍是固定 1K 或竖版回落尺寸;画面只是后端放大后的低分辨率结果。 @@ -2283,7 +2284,7 @@ - 现象:在“登记商户平台退款”里填写 `50000000000000000000000000000` 一类微信退款单号后提示找不到退款。 - 原因:该编号是微信侧 `refund_id`;V3 单笔退款查询路径只接受商户退款单号 `out_refund_no`。把 `refund_id` 放进路径不会自动转换,退款查单会返回 `RESOURCE_NOT_EXISTS`。不过微信没有承诺 `refund_id` 固定为 `50` 开头的 29 位数字,合法 `out_refund_no` 也可能是纯数字,因此形状判断不能代替真实查单。 -- 处理:登记表单明确标注 `out_refund_no`,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 `50` 开头的 29 位纯数字作为“疑似 refund_id”给出定向提示。只有 `refund_id` 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。`out_refund_no` 字符校验须覆盖官方允许的数字、大小写字母和 `_ - | * @`。 +- 处理:登记表单明确标注 `out_refund_no`,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 `50` 开头的 29 位纯数字作为“疑似 refund*id”给出定向提示。只有 `refund_id` 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。`out_refund_no` 字符校验须覆盖官方允许的数字、大小写字母和 `* - | \* @`。 - 验证:后台页面测试断言疑似编号仍交给服务端;平台适配器测试锁定 `RESOURCE_NOT_EXISTS` 映射和 `@` 字符,并确认真正的 `out_refund_no` 仍调用 `GET /v3/refund/domestic/refunds/{out_refund_no}`。 - 关联:`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx`、`server-rs/crates/api-server/src/admin_recharge.rs`、`server-rs/crates/platform-wechat/src/pay.rs`。 @@ -3135,6 +3136,7 @@ - 处理:产品 UI 不提供模型选择,应用内调用固定 `birefnet`;外部编辑器 OpenAPI 不声明 `segModel`,并保持相关请求 schema 的 `additionalProperties: false`,使外部请求携带该字段时被契约拒绝。只有维护 BgFilter 模型与容量的后端代码可使用该内部字段;若未来需要开放,先完成各模型的内存、并发和超时压测,再明确版本化外部契约。 - 验证:检查 `docs/openapi/genarrative-external-v1.openapi.json` 的图片生成、图标 spritesheet 和 UI 素材提取请求 schema 均未包含 `segModel`,且均保持 `additionalProperties: false`。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、`docs/project-memory/shared-memory/decision-log.md`。 + ## SPA 路由白名单不能只按一级目录放行 - 现象:`/not-exist` 已返回 404,但 `/creation/not-exist`、`/runtime/not-exist` 或 `/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。 @@ -3191,6 +3193,7 @@ - Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。 - 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。 - 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 持久进程恢复不能重放 start 或按 PID 重连 - 现象:Runner 强杀或重启后,同一个 run 又启动了一份开发服务器,或者新 Runner 根据旧 PID 把宿主上的同号进程误认成原 PTY 会话。 @@ -3460,6 +3463,7 @@ - 验证:新命令为 `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e -- --config-dir <发布AppData绝对路径>`;旧 `supervisor-swarm-transient-retry` 只保留首次 tool-plan 证明。fault proxy `14/14`、E2E self-test **PASS**、前端 `308/308` 及 shell typecheck、`platform-llm 41/41`、`platform-agent 17/17`、`shared-contracts 7/7` 等确定性门禁已完成。真实外部 Provider suite 共执行六轮,前五轮均为 **FAIL** 且不得拼接:第一、二轮沿用既有失败记录,第三轮为终态后过早观察到 `1` 个 finalization journal,第四轮为 quality-review 普通 tool-plan 连续 transport/connectivity 失败耗尽重试且未进入目标故障,第五轮为上述写锁竞争与绝对锁路径问题;修复后第六轮在同一轮内完整 **PASS**。 - 第六轮证据:`gpt-5.5 / openai_chat`,`2` 条初始加 `1` 条 repair delivery、`3` 条专业 Agent assistant,Project Supervisor base final-reply 目标与可信宿主 verify marker 门禁均成立;受控 Provider `failed=1 / retry=1`、incidental `failure=0 / retry=0`,`30s` backoff,pidfd `claim=2 / signal=2`,Runner `resumed=true / identityStable=true`。父 tool-plan 故障前后均为 `13`,parent final-reply 与最终 assistant 唯一,response stream `sequence=2 / committed`;pending、retry、handoff、finalization、confirmation sidecar、全部重复计数及 API Key、私有正文、项目路径、正式配置路径和公共报告泄漏扫描命中均为 `0`。V1.41 handoff 原子提交前的 unknown-result 和 tool-plan handoff 未覆盖边界继续保留。 - 关联:`apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs`、`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`、`apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + ## SpacetimeDB 历史归档不能按文件名小于 snapshot 就全部删除 - 现象:看到最新 `N.snapshot_dir` 后,把所有起始 offset 小于 `N` 的 `.stdb.log` 删除,或者只把旧日志上传 OSS 就宣称已有完整增量灾备。 @@ -3619,3 +3623,41 @@ - 处理:保留 queue busy 用于 Runner 存活判断,另由 Runtime 协议层提供唯一 steerable 判定。start mutation 返回实际 `acceptedRunId`,CLI 以它建立不可变 turn baseline;失败、reconciliation、用户交互、收束和报告只观察该 run。canonical 已推进到后续 run 时从 task journal 读取目标 run 的最终记录。旧 cancelled canonical 若仍有 pending 且无 running,恢复扫描跳过旧 run 的 pending action 恢复,直接启动队首 pending。若输入与已落盘 pending task 及最后一条 user 消息相同,则只观察原 run。Goal 路径也必须核对同一 Agent、Session、runId、Run Profile 和 steerable 状态。连续 run 的回复必须按确定性 finalization message ID 过滤;历史 specialist 失败必须以 `(agentId, runId)` 为键读取完整 journal,不能让滞后的非失败 state 删除 journal 已记录的失败;报告计数也不能退回 `recent_tasks` 的 12 条窗口。 - 验证:构造 cancelled run A、保留 A cancel tombstone、pending run B 和单份已落盘用户消息,证明恢复后 B 进入 running 并完成且 conversation 不重复。另覆盖观察 B 时忽略 A 及 A 子任务失败、观察 A 时仍正常失败、B 完成后 canonical 已推进到 C 仍可从 journal 收束 B、`turn.report.parentRunId` 始终为 baseline,以及 expected Goal runId 不一致时不选中目标。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`。 + +## 2026-07-25 autonomous-game-build 不能只检查 game/index.html 就宣称正式项目完成 + +- 现象:`agc:test:chat` 收束后可能只有可玩 `game/index.html`,配置画布 Key 时可能再有一张首版美术图,但 `memory/`、`exports/` 仍为空,设计、数值、美术清单、音频需求和发布包装文件缺失;界面或 CLI 却仍可能显示已完成。 +- 原因:“可玩原型”和“正式项目”共用了同一完成信号。首批静态委派只覆盖程序、质量和可选美术,Supervisor 完成门与 `agc:test:chat` 收束检查又主要围绕 `game/index.html`、静态 smoke 和试玩回执;seed manifest 中其他任务的终态和正式文件集没有成为硬门禁。 +- 处理:将自主构建改为正式产物 DAG,按“设计打底 -> 数值/美术/音频并行 -> 程序整合 -> 质量/静态/试玩 -> 发布包装”分波调度。基础正式路径必须包含 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`;配置画布 Key 时再额外要求 `assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,并校验非空、JSON 可解析、图片真实可读和 manifest 登记。 +- 补充原因:历史 seed 状态未重置、普通 preview / smoke 自动投影和在 `agent.run_status` claim 尚未可靠观察时过早启动 DAG,都会让任务看似完成却没有真实执行;恢复扫描重新发布已经冻结或认领的 delivery,还会刷出重复 `agent.delegate.result_failed`。 +- 特殊边界:`assets/manifest.audio.json` 只是当前阶段的音频需求清单,不能冒充 BGM/SFX 已生成。无画布 API Key 时交付 7 项文本 / JSON / 代码 / 发布产物,并明确图片尚未生成;配置 Key 时额外要求两张受控画布图片,Key 无效或生成失败必须阻塞,不得用占位文件或伪造登记绕过。 +- 收束:Supervisor 只有在当前轮所有必需 seed manifest tasks 都为 `completed`、正式产物齐全且可解析、当前最新 revision 的 `game.static_smoke` 和 `preview.validate` 通过后才能最终回复。严格 `agc:test:chat` 必须精确校验固定 16 个 manifest task 在同一父 Run 下各自唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection,并核对 current revision 静态凭证、桌面 / 移动浏览器 playtest、截图与报告;不能把 delivery 完成、历史 revision 成功、文件存在、非空、JSON 可解析或 PNG magic 命中当成正式完成。PNG 还必须通过 chunk CRC、zlib、scanline、PLTE 和未知 critical chunk 检查;当前脚本已同时绑定 current revision 的 static smoke 与浏览器证据。 +- 处理:新自主根 run 重置全部 16 个 seed task;DAG 在 `run_status` claim 可靠观察后启动并等待项目写锁;普通 bookkeeping 不修改自主 DAG;已 `ready / claimed-by-parent` 的相同终态 delivery 重放按幂等成功处理,只有真实终态冲突才写失败。 +- 验证:确定性 `npm run agc:test` 已通过,16 个 manifest task exactly-once,最终 revision 为 `11`,基础正式产物、两张画布 PNG、静态 smoke 和桌面 / 移动 `37/37` 试玩均通过,pending、reconciliation、Provider 失败、重复与泄漏均为 `0`。该证据不冒充独立外部 Provider 验收。 + +## 终端真实测试不能混用配置参数、stdin EOF 和持续预览 + +- 现象:开发者第一次运行 `agc:test:chat` 时必须先打开 GUI 才能配置 Provider;无 TTY 的脚本可能在 stdin 立即 EOF 后零任务成功退出,或者任务已经完成却继续等待 preview 的 `Ctrl+C`,导致自动化看似卡死。若为图省事增加 `--api-key`,密钥还会进入 shell history 和进程列表。 +- 原因:把首次配置、手工多轮聊天、单轮真实测试和持续试玩当成同一个交互生命周期;同时让 GUI 与 CLI 使用不同配置入口,或把 EOF 既解释为“提交当前需求”又解释为“没有输入”,会让退出语义随调用环境漂移。 +- 处理:GUI 与 `npm run agc:config` 共用系统 AppData `game-creator.config.json`,终端隐藏输入 API Key 并禁止 `--api-key`;更新时保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 权限维持目录 `0700` / 文件 `0600` 并原子替换。显式 `--config-dir` 必须以 `world.genarrative.ai-game-creator` 为独立叶目录,不能让向导对 `/tmp`、AppData 根或共享目录整体 chmod / 重建 DACL。隐藏输入调用 `stdin.resume()` 后必须记住原 pause 状态,在成功、取消、异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复 raw mode 并 `pause()`,信号恢复后重发;只移除 `data` listener 会让 `--configure-only`、配置检查失败或 Ctrl+C 保持活动 stdin。缺配置时仅 TTY 人工会话可询问进入向导,非 TTY 立即失败并提示配置命令。 +- Windows 密钥复制:`mode: 0o600` 和 POSIX `chmod` 在 Windows 上不能代替 DACL。隔离 AppData 目录必须先设置仅当前用户、禁止继承的 DACL;目标配置文件先以空文件创建并收紧 DACL,之后才允许把 API Key 字节写入。先 `copyFile` 再依赖 Rust 只读检查或事后收紧会留下密钥暴露窗口,也可能因继承 ACL 不满足 Runtime 合同而在首次 `--llm-status` 失败。 +- 超时与进程树:`setTimeout` 后只对直接 Cargo PID 调一次 `kill()` 不是硬超时;Cargo 启动的 CLI / Runner 仍可能持有 stdio,使 `close` 永远不返回,清理阶段也可能无界等待。POSIX 必须创建独立进程组并按负 PID 终止,Windows 必须使用 `taskkill /T`;宽限期后升级强杀,Runner shutdown 和清理另设短硬超时。重复 Ctrl+C 也必须升级,不能一直被自定义 signal handler 吞掉。 +- 模式拆分:自动化使用默认的 `agc:test:chat`,由脚本投递固定植物塔防需求;正式产物、最新 revision 静态检查和 Runtime 浏览器验收通过后立即收束并清理,不启动持续 preview。`agc:test:chat:manual` 不注入 `--task`,用于多轮 stdin 手工聊天,并保留 preview 直到显式退出;不能用 EOF 或是否存在 `game/index.html` 猜测当前模式。 +- 验证:覆盖 `--api-key` 拒绝、各平台 AppData 路径、Provider 预设、旧配置节点保留、URL 安全校验、原子写入、POSIX 权限、隐藏输入恢复 pause、Windows 写密钥前 DACL、整棵进程树超时终止、缺配置时 TTY / 非 TTY 分支,以及 `--task` 成功后不进入长期 preview、手工模式仍可持续试玩。测试和错误输出只验证“密钥已配置”状态,不读取或打印密钥本体;源码字符串断言和 Linux 上的 Windows mock 不能替代真实 Windows ACL / `taskkill` 复验。 +- 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。 +- 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。 + +## 固定画布产物返工不能变成任意覆盖,design-foundation 不能越权修程序 + +- 现象:视觉 Agent 发现候选图不合格后,可能先删除 `assets/ui-prototype.png` 或 `assets/art-spritesheet.png`,再用猜测的尺寸、比例或另一条路径重新生成;远端生成期间项目文件又可能被其它 Agent 更新,迟到结果覆盖较新的文件。`design-foundation` 为了让静态或浏览器检查通过,也可能顺手改写 `game/index.html` 或自行启动 preview。 +- 原因:把“允许一次语义返工”误解成“视觉 Agent 可以任意覆盖”,且只在 prompt 中描述角色职责,没有在 replacement 授权、文件写入、工具策略和提交时 fingerprint 上强制执行。 +- 处理:固定 UI 与 spritesheet 路径、比例、尺寸、kind 和 label;普通生成 `replaceExisting=false`。只有 Project Supervisor 对已认领原 delivery 建立的唯一静态 repair,且父 run、目标 Agent 与 `expectedArtifacts` 全部匹配时,才允许 `replaceExisting=true` 原位替换;不得先删除固定正式产物,也不得对 repair 再 repair。请求外部生成前记录原路径 SHA-256,取得写锁准备提交时复算;不一致即按 stale fingerprint 失败关闭并保留当前文件。 +- 职责隔离:`design-foundation` 只写 `memory/project.md`、`game/game_design.md` 和可选固定 UI 原型。Runtime 必须同时在单文件写入、patchset、delete 与工具 policy 层拒绝其修改 `game/index.html`、其它实现文件、启动 preview / playtest、运行进程、调用 `game.static_smoke` 或整项目恢复;只有 `preview-readiness` 可执行固定 smoke,只有 `preview-playtest` 可执行浏览器验收。 +- 验证与状态:当前回归已覆盖固定合同拒绝漂移、已登记 spritesheet 删除保护、静态 repair 授权、并发修改触发 stale fingerprint、`design-foundation` 的 write / patchset / delete 和 preview 工具拒绝。2026-07-27 的独立 75 分钟上限外部 E2E 已在同一轮完成两张真实画布图片、固定 `16` 任务、当前 revision 静态与双视口试玩并安全清理,当前状态为 **PASS**;后续改动仍须新轮复验。 + +## 完成合同不能只绑定一个入口摘要,公开资源审计不能保存完整 prompt + +- 现象:历史 completion contract 只绑定 `game/index.html`,可能在其它正式产物沿用旧文件时仍放行;画布生成成功后,`asset.register` 又把完整 `source.prompt` 复制进公开 Agent DB,触发 provider payload / private-body 表面泄漏。 +- 原因:把“入口变了”误当成“本轮全部正式产物都新鲜”,并直接复用资源 manifest 的完整 source 对象写公开审计。manifest 的本地来源元数据与公开 event / agentDb 的最小身份字段不是同一个安全边界。 +- 处理:完成合同升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并参与指纹;旧 v1、缺基线或提交前身份漂移失败关闭。资源 manifest 可以保留 prompt,但 `asset.register / asset.update` 写审计前必须清除 `source.prompt`,只保留资源身份和模型。 +- 并发补验:revision 漂移、repository context drift 和项目锁竞争只能在同一 logical run 有界重试;只有明确 blocker / repair 或成功 `ok` observation 才可重放终态,失败 observation 不能当通过,只读 Agent 不能借补验获得命令权限,completion 统计仍只能增加一次。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5e706bbc9..2c03c9552 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -88,6 +88,8 @@ V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独 2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData,把 `game-creator.config.json` 和存在时的 `game-creator.config.local.json` 私有复制到单次 sentinel 隔离 AppData,绝不复制正式 Runner endpoint、lock、备份或其它文件,再创建带私有 sentinel 的一次性项目。LLM 状态检查和 `--swarm-chat --init --autonomous-game-build` 都只使用隔离 AppData,因此当前 debug 二进制指纹变化不会探测、退役或阻塞正在工作的正式客户端 Runner。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后,脚本通过内部 `--runner-shutdown-if-idle` 只关闭已空闲的隔离 Runner,确认 endpoint 消失后再验证 sentinel 并清理隔离 AppData 和项目;隔离 Runner 仍有任务、退出失败、Swarm 未收束或预览启动失败时保留对应现场,不能强杀或误删。`--keep-project` 可主动保留项目但不额外保留已空闲的隔离配置;显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` 只表示绝对配置来源目录,`--project-dir` 也只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 +2026-07-25 补充并取代上一段的统一持续预览口径:`npm run agc:test:chat` 默认投递固定植物塔防需求,作为单轮非交互真实测试,不依赖 stdin,也不把 EOF 当作“没有任务”。父 run 收束后必须校验本轮必需 seed task、当前配置对应的正式产物、最新 revision 静态检查和 Runtime 浏览器验收;全部通过后立即关闭空闲隔离 Runner,并按 sentinel 与保留参数清理测试 AppData / 项目,不再进入等待 `Ctrl+C` 的长期 preview。`npm run agc:test:chat:manual` 才是不带 `--task` 的手工聊天入口,继续读取多轮 stdin,并在成功收束后保留持续预览供人工试玩,直到用户显式退出。自动化、CI 或无 TTY 调用必须使用默认自动入口,不能依赖交互提示或持续预览生命周期。 + 2026-07-24 起,`--swarm-chat` 的普通自然语言不再由 CLI 关键词表预分类为 Chat / Execute / Resume。Project Supervisor 与六个部门 Director 进入同一个轻量 interaction loop:模型可以直接回复,或从统一 capability registry 中选择 `project_location / runtime_execute / runtime_resume`;`runtime_execute` 不接收模型改写后的任务正文,宿主始终把原始用户消息提交给持久 Runtime。显式 `/status /goal /resume` 继续属于确定性控制面。匹配 Session 已有 busy Runtime 或 active Goal 时,新消息不启动旁路 Provider,也不直接改写同一 canonical Session,而是进入原 run 的 durable steer;所有 Runtime mutation 继续要求项目外 `--config-dir`、External Runner、Agent task lock、Session lane、权限、确认、revision、verification 和 finalization 门禁。该 interaction registry 是 Pi 风格小内核与可组合工具边界的第一步,不代表现有 Runtime 工具已经完成单一 registry 迁移,也不允许把安全不变量下放到 Prompt、Skill 或模型判断。 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 @@ -468,6 +470,7 @@ game-project/ - `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 只嵌入当前 loopback 游戏预览、用户侧预览命令不调用系统浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记一个用户窗口,登录后同一 WebView 渲染首页、项目组和项目开发页,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,旧窗口切换 command 仅保留兼容、不进入用户主流程。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 +- `npm run agc:config`:提供终端交互式配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导更新 LLM 主配置时必须保留已有 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。`agc:test:chat` 找不到有效配置时,只在 stdin / stdout 均为 TTY 的人工运行中询问是否进入该向导;非 TTY 直接失败并提示先运行 `npm run agc:config`,不得等待输入或写入默认密钥。 - `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 ## 当前最小落地 @@ -718,3 +721,14 @@ game-project/ - V1.46 的 Runner 恢复会把旧版本已持久化的自主 `pending-confirmation / waiting-confirmation` 迁移为 `observed-rejected / aborted`。Provider batch 是原子提交点,pending 只是可重建镜像;批次先落盘、pending 后落盘之间强杀时,下次恢复从 aborted batch 补齐拒绝账本并继续原 Session/run。公共状态和审计使用 `runtime-policy-rejected`,不得写成“已执行自动工具”或“开发者拒绝”。 - V1.46 稳定树通过自主构建过滤 `20/20`、确认相关 `18/18`、旧等待态完整恢复、既有批次零副作用、`cargo check --tests`、Rust 串行全量 `1149 passed / 5 ignored / 0 failed`、fmt 与 diff 检查。确定性正式 E2E 为 **PASS**:Provider lifecycle `17/17`、revision `0 -> 2`、Chrome `37/37`、人工输入与残留均为 `0`。新的独立外部 Provider 同轮 E2E 也为 **PASS**:单条任务后 EOF,approve / answer / steer 为 `0`,Provider lifecycle `62/62`,revision `0 -> 5`,`game/index.html` 为 `7816` bytes,static smoke、desktop / mobile 与 `lane-defense-v1 37/37` 全通过,唯一 Supervisor assistant,全部 sidecar、reconciliation、重复和泄漏计数均为 `0`。 - 2026-07-25 补充:Swarm CLI 将“Agent 执行通道仍忙”和“canonical run 可接受 steer”拆为两个判定。`task_queue.pending > 0` 继续用于 Runner 存活观察,但只有底层 steer 门禁认可的 `running / waiting-for-confirmation` Runtime 才能接收新指令;`completed/cancelled run A + pending run B` 必须先通过 Runner 恢复 B,禁止把消息追加到 A。start mutation 必须返回实际 `acceptedRunId`,CLI 以它建立 turn baseline,失败扫描、交互、收束和 `turn.report` 都只认 baseline run;若 Runner 已连续推进到 C,则从 task journal 读取 B 的终态。旧 A 的 cancel tombstone 不得阻断队首 B 恢复。若用户重复输入的内容正是已经落盘的 pending 任务且它仍是对话最后一条 user 消息,CLI 只观察原 run,不再追加第二份对话或创建新任务;active Goal 同样必须同时匹配 Agent、Session、runId、Run Profile 和可 steer 状态。连续 B/C 的 assistant 回复按 finalization message ID 归属,observer 不输出非目标 run 的状态或流;历史失败和报告计数都读取完整 task journal,失败聚合使用 `(agentId, runId)`,journal 失败终态不能被滞后的 state 投影覆盖。 +- 2026-07-25 已落地:`autonomous-game-build` 的完成合同从“交付可玩原型”升级为“交付正式项目产物”。Runtime 按 seed manifest 的 16 个 task 依赖分波推进:先设计打底,再并行完成数值、美术和音频需求,然后程序整合,之后执行质量审查、当前 revision 静态检查与真实试玩,最后生成发布包装。每个新根 run 先重置本轮 seed task;普通 preview / smoke bookkeeping 不得代替自主 task 的真实执行和终态投影。 +- DAG 只能在 Project Supervisor 的 `agent.run_status` 回执认领已可靠观察、静态委派屏障清空后启动;正常执行、pending 续跑和重启恢复复用同一入口。调度等待项目写锁,不能因短暂锁竞争进入 reconciliation。专业子 run 的验证允许 `verifiedRevision >= mutationRevision`,但 Supervisor 的最终静态和试玩证据仍必须精确覆盖最新全局 revision。 +- 正式项目基础产物固定为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`。未配置画布 API Key 时,设计与美术 Agent 交付明确记录界面结构、素材需求和“尚未生成”状态的文本 / JSON,不暴露 `canvas.asset_generate`,也不得伪造图片;配置 Key 时额外强制生成、登记并验收 `assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,生成失败不得完成。`assets/manifest.audio.json` 当前只表示 BGM 与关键音效需求,不得宣称已生成真实音频文件。 +- Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 +- 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 +- 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 +- `npm run agc:test:chat` 未显式指定配置且找不到 AppData 配置时,只在 stdin / stdout 都是 TTY 时询问并启动同一 `agc:config --configure-only` 向导,非 TTY 或显式无效 `--config-dir` 直接失败。测试环境只把主配置和存在时的 local overlay 复制到带随机 sentinel 的单次隔离 AppData;副本必须是独立的无符号链接普通文件,POSIX 权限为目录 `0700` / 文件 `0600`,不复制正式 Runner endpoint、lock 或其它 AppData。自动任务默认 50 分钟且可用 `--timeout-minutes` 显式设置;超时或信号会终止独立子进程树,POSIX 先向进程组发送 `SIGTERM`、等待 10 秒后发送 `SIGKILL` 并再等待 5 秒,Windows 使用 `taskkill /T` 并在强制阶段追加 `/F`。超时和信号分别以 `124 / 130 / 143` 失败退出,隔离 Runner 收束另有 20 秒上限;Runner 未空闲或收束失败时保留隔离配置和项目,验收未完成但 Runner 已安全退出时只保留一次性项目证据,不把中断报告为成功,也不误删正式 AppData。 +- 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加两张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。确定性 `npm run agc:test` 已证明 revision `0 -> 11`、双视口试玩 `37/37` 和终局零残留;该证据仍不替代外部 Provider 单轮验收。 +- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 +- `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 +- 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 diff --git a/package.json b/package.json index 3ed5719f8..9678309f2 100644 --- a/package.json +++ b/package.json @@ -131,11 +131,13 @@ "agc:serve": "npm run agc:typecheck && npm --prefix apps/ai-game-creator-shell run dev-stack", "agc:vite": "npm --prefix apps/ai-game-creator-shell run dev-server", "agc:backend": "node scripts/dev.mjs backend", + "agc:config": "npm --prefix apps/ai-game-creator-shell run config --", "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:check": "npm run ai-game-creator-shell:check", "agc:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", "agc:test": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --", "agc:test:chat": "npm --prefix apps/ai-game-creator-shell run test:chat --", + "agc:test:chat:manual": "npm --prefix apps/ai-game-creator-shell run test:chat:manual --", "ai-game-creator-shell:dev": "npm --prefix apps/ai-game-creator-shell run dev", "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --",