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) ||
+ !/