diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
index 9aeaf988b..44135c949 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -51,7 +51,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| --- | --- | --- | --- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
-| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
+| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -92,6 +92,8 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
+`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
+
## Common Values
Use OpenAPI as the final authority; these common values are a routing aid:
diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
index 855cc0f16..21f881bc0 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -78,9 +78,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
-3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`.
+3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
-For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ` `, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
+For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ` `, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml
index 0b20e28e2..f56925002 100644
--- a/.gitea/workflows/project-ci.yml
+++ b/.gitea/workflows/project-ci.yml
@@ -69,6 +69,9 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
+ - name: Install AI game creator dependencies
+ run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell
+
- name: Run repository checks
run: npm run check:repository-ci
diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json
index 2b3f24be6..4dd14447d 100644
--- a/apps/ai-game-creator-shell/game-creator.config.json
+++ b/apps/ai-game-creator-shell/game-creator.config.json
@@ -2,11 +2,11 @@
"agentMode": "codex_app_server",
"llm": {
"apiKey": "",
- "baseUrl": "https://api.openai.com/v1",
- "model": "gpt-4.1",
+ "baseUrl": "https://dev.genarrative.world/gpt/v1",
+ "model": "gpt-5.6-sol",
"apiKind": "openai_responses",
- "reasoningEffort": "high",
- "stream": false,
+ "reasoningEffort": "max",
+ "stream": true,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
@@ -16,10 +16,6 @@
"retryBackoffMs": 500
},
"agentLlm": {},
- "editorApi": {
- "baseUrl": "http://127.0.0.1:8082",
- "apiKey": ""
- },
"mcpServers": {},
"planning": {
"capabilityEnabled": true
diff --git a/apps/ai-game-creator-shell/index.html b/apps/ai-game-creator-shell/index.html
index fa69c5471..9de1d37b8 100644
--- a/apps/ai-game-creator-shell/index.html
+++ b/apps/ai-game-creator-shell/index.html
@@ -4,7 +4,7 @@
-
AI 游戏创作
+ 陶泥儿
diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json
index 5ad694034..69bd74590 100644
--- a/apps/ai-game-creator-shell/package.json
+++ b/apps/ai-game-creator-shell/package.json
@@ -24,6 +24,7 @@
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
+ "agent-runtime:supervisor-game-chat-single-main-playable-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs
index 0bf11ca70..89f552dc7 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import {
createDeterministicLaneDefenseRouter,
+ deterministicLaneDefenseCanonicalHtml,
deterministicLaneDefenseInitialHtml,
deterministicLaneDefenseModel,
deterministicManifestReadyAgentIds,
@@ -50,6 +51,32 @@ function parseArguments(args) {
return { keepProject, selfTest };
}
+function deterministicRuntimeConfig(apiKey, provider) {
+ return {
+ agentMode: 'provider',
+ editorApi: {
+ apiKey,
+ baseUrl: provider.editorBaseUrl,
+ },
+ llm: {
+ apiKey,
+ baseUrl: provider.baseUrl,
+ model: deterministicLaneDefenseModel,
+ apiKind: 'openai_chat',
+ reasoningEffort: 'max',
+ stream: false,
+ requestTimeoutMs: 30_000,
+ maxRetries: 0,
+ retryBackoffMs: 100,
+ },
+ agentLlm: {
+ 'project-supervisor': {
+ reasoningEffort: 'max',
+ },
+ },
+ };
+}
+
function appendBounded(current, chunk) {
const combined = Buffer.concat([current, chunk]);
if (combined.length > outputLimit) throw new Error('child-output-too-large');
@@ -117,71 +144,14 @@ function parseChildReport(result) {
}
}
-function responsibilityContractEvidence(stats) {
- const qualityPlanningCount =
- stats?.byAgent?.['quality-review']?.planning ?? null;
- const evidence = {
- delegationContractCount: stats?.delegationContractCount ?? null,
- delegationContractViolationCount:
- stats?.delegationContractViolationCount ?? null,
- codePrototypeDelegationCount: stats?.codePrototypeDelegationCount ?? null,
- codePrototypeExpectedArtifactCount:
- stats?.codePrototypeExpectedArtifactCount ?? null,
- codePrototypeGameIndexArtifactDelegationCount:
- stats?.codePrototypeGameIndexArtifactDelegationCount ?? null,
- codePrototypeProjectMutationCount:
- stats?.byAgent?.['code-prototype']?.projectMutation ?? null,
- qualityReviewDelegationCount: stats?.qualityReviewDelegationCount ?? null,
- qualityReviewReadOnlyDelegationCount:
- stats?.qualityReviewReadOnlyDelegationCount ?? null,
- qualityReviewExpectedArtifactCount:
- stats?.qualityReviewExpectedArtifactCount ?? null,
- artAssetPlanDelegationCount: stats?.artAssetPlanDelegationCount ?? null,
- artAssetPlanExpectedArtifactCount:
- stats?.artAssetPlanExpectedArtifactCount ?? null,
- artAssetPlanArtifactContractCount:
- stats?.artAssetPlanArtifactContractCount ?? null,
- qualityReviewProjectMutationCount:
- stats?.byAgent?.['quality-review']?.projectMutation ?? null,
- qualityPlanningCount,
- qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null,
- };
- return {
- ...evidence,
- codeOwnsGameIndex:
- Number.isInteger(evidence.codePrototypeDelegationCount) &&
- evidence.codePrototypeDelegationCount >= 1 &&
- evidence.codePrototypeExpectedArtifactCount ===
- evidence.codePrototypeDelegationCount &&
- evidence.codePrototypeGameIndexArtifactDelegationCount ===
- evidence.codePrototypeDelegationCount &&
- Number.isInteger(evidence.codePrototypeProjectMutationCount) &&
- evidence.codePrototypeProjectMutationCount >= 2,
- qualityIsReadOnly:
- evidence.qualityReviewDelegationCount === 1 &&
- evidence.qualityReviewReadOnlyDelegationCount === 1 &&
- evidence.qualityReviewExpectedArtifactCount === 0 &&
- evidence.qualityReviewProjectMutationCount === 0,
- qualityIndependentOfCodeTiming:
- Number.isInteger(qualityPlanningCount) &&
- qualityPlanningCount >= 1 &&
- Number.isInteger(evidence.qualityRevisionReplanCount) &&
- evidence.qualityRevisionReplanCount >= 0 &&
- evidence.qualityRevisionReplanCount <= 1,
- artOwnsGeneratedAsset:
- evidence.artAssetPlanDelegationCount === 1 &&
- evidence.artAssetPlanExpectedArtifactCount === 2 &&
- evidence.artAssetPlanArtifactContractCount === 1,
- contractViolationFree:
- Number.isInteger(evidence.delegationContractCount) &&
- evidence.delegationContractCount >= 3 &&
- evidence.delegationContractViolationCount === 0,
- };
-}
-
function expectedProviderStats(stats) {
- const responsibility = responsibilityContractEvidence(stats);
const manifestReadyTasks = manifestReadyTaskEvidence(stats);
+ const projectSupervisorRuntimePlanningCount =
+ Number.isInteger(stats.byAgent?.['project-supervisor']?.planning) &&
+ Number.isInteger(stats.interactionExecuteCount)
+ ? stats.byAgent['project-supervisor'].planning -
+ stats.interactionExecuteCount
+ : null;
return (
Number.isInteger(stats.requestCount) &&
stats.requestCount >= 40 &&
@@ -191,38 +161,40 @@ function expectedProviderStats(stats) {
(stats.imageInspectionRequestCount ?? 0) ===
stats.requestCount &&
stats.finalReplyRequestCount === 0 &&
- stats.initialDelegationCount === 3 &&
- stats.followupDelegationCount === 0 &&
+ stats.interactionExecuteCount === 1 &&
+ stats.goalContractCount === 1 &&
+ stats.actionHistoryCount === 1 &&
+ stats.acceptanceUpdateCount === 1 &&
+ stats.delegateActionCount === 0 &&
stats.runStatusCount >= 1 &&
stats.sourceWriteCount >= 7 &&
stats.staticSmokeCount >= 4 &&
stats.previewValidationCount >= 2 &&
- stats.supervisorDirectMutationAttemptCount === 0 &&
manifestReadyTasks.exactlyOnce &&
stats.manifestReadyTaskFileWriteCount >= 7 &&
stats.manifestReadyTaskPreviewValidationCount >= 1 &&
- stats.manifestReadyTaskCanvasGenerationCount >= 1 &&
- stats.manifestReadyTaskCanvasGenerationCount <= 2 &&
+ stats.manifestReadyTaskCanvasGenerationCount >=
+ stats.canvasGenerationRequestCount &&
+ stats.manifestReadyTaskCanvasGenerationCount <=
+ stats.canvasGenerationRequestCount + 16 &&
stats.imageInspectionRequestCount === 1 &&
- stats.canvasGenerationRequestCount === 2 &&
- stats.canvasDownloadRequestCount === 2 &&
+ stats.canvasGenerationRequestCount === 3 &&
+ stats.canvasDownloadRequestCount === 3 &&
+ stats.canvasSliceDownloadRequestCount === 4 &&
stats.canvasGeneratedAspectRatios?.['16:9'] === 1 &&
- stats.canvasGeneratedAspectRatios?.['1:1'] === 1 &&
+ stats.canvasGeneratedAspectRatios?.['1:1'] === 2 &&
stats.canonicalCodeRunCount === 1 &&
+ stats.canonicalCodeAssetListCount === 1 &&
stats.unexpectedRequestCount === 0 &&
Object.keys(stats.rejectionCodes ?? {}).length === 0 &&
- stats.byAgent?.['project-supervisor']?.planning >= 5 &&
+ projectSupervisorRuntimePlanningCount >= 5 &&
stats.byAgent?.['project-supervisor']?.finalReply === 0 &&
stats.byAgent?.['project-supervisor']?.projectMutation === 0 &&
- stats.byAgent?.['code-prototype']?.planning >= 6 &&
+ stats.byAgent?.['code-prototype']?.planning >= 5 &&
stats.byAgent?.['code-prototype']?.finalReply === 0 &&
- stats.byAgent?.['code-prototype']?.projectMutation >= 2 &&
+ stats.byAgent?.['code-prototype']?.projectMutation === 1 &&
stats.byAgent?.['quality-review']?.finalReply === 0 &&
- responsibility.codeOwnsGameIndex &&
- responsibility.qualityIsReadOnly &&
- responsibility.qualityIndependentOfCodeTiming &&
- responsibility.artOwnsGeneratedAsset &&
- responsibility.contractViolationFree
+ stats.byAgent?.['quality-review']?.projectMutation === 0
);
}
@@ -362,6 +334,53 @@ function syntheticPayload(agentId, runId, tools, extraContext = '') {
};
}
+function syntheticInteractionExecutePayload() {
+ return {
+ model: deterministicLaneDefenseModel,
+ stream: false,
+ reasoning_effort: 'max',
+ messages: [
+ {
+ role: 'system',
+ content: '你现在位于统一的 Agent interaction loop。',
+ },
+ {
+ role: 'user',
+ content: '用户这轮输入:\n制作一个三消经营游戏',
+ },
+ ],
+ tools: ['runtime_execute', 'runtime_resume', 'project_location'].map(
+ (name) => ({ type: 'function', function: { name } }),
+ ),
+ };
+}
+
+function syntheticUiPrototypeInspectionPayload() {
+ return {
+ model: deterministicLaneDefenseModel,
+ stream: false,
+ messages: [
+ {
+ role: 'system',
+ content: '你是游戏界面视觉检查 Agent。只分析画面。',
+ },
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: '请逐项检查 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme,并只返回严格 JSON。',
+ },
+ {
+ type: 'image_url',
+ image_url: { url: 'data:image/png;base64,AA==' },
+ },
+ ],
+ },
+ ],
+ };
+}
+
function responseFunctionNames(response) {
return response.choices[0].message.tool_calls.map(
(call) => call.function.name,
@@ -387,12 +406,49 @@ async function runSelfTest() {
html.includes('requestAnimationFrame'),
'self-test-html-contract-invalid',
);
+ const canonicalHtml = deterministicLaneDefenseCanonicalHtml();
+ assert(
+ canonicalHtml.includes(visibleCanvasCss) &&
+ canonicalHtml.includes("atlasArt.src='../assets/art-spritesheet.png'") &&
+ [
+ 'player.png',
+ 'blocks-and-targets.png',
+ 'obstacles-and-scene.png',
+ 'feedback-effects.png',
+ ].every((fileName) =>
+ canonicalHtml.includes(`../assets/art-spritesheet-slices/${fileName}`),
+ ) &&
+ (canonicalHtml.match(/ctx\.drawImage\(/g) ?? []).length >= 5,
+ 'self-test-canonical-visible-art-contract-invalid',
+ );
const apiKey = `deterministic-self-test-${randomUUID()}`;
- const router = createDeterministicLaneDefenseRouter({ apiKey });
+ const runtimeConfig = deterministicRuntimeConfig(apiKey, {
+ baseUrl: 'http://127.0.0.1:41001/v1',
+ editorBaseUrl: 'http://127.0.0.1:41002',
+ });
+ assert(
+ runtimeConfig.agentMode === 'provider' &&
+ runtimeConfig.llm.apiKind === 'openai_chat' &&
+ runtimeConfig.llm.reasoningEffort === 'max' &&
+ runtimeConfig.agentLlm?.['project-supervisor']?.reasoningEffort ===
+ 'max' &&
+ runtimeConfig.llm.apiKey === apiKey &&
+ runtimeConfig.editorApi.apiKey === apiKey,
+ 'self-test-provider-runtime-config-invalid',
+ );
+ const captureProviderErrorCode = (operation) => {
+ try {
+ operation();
+ return null;
+ } catch (error) {
+ return error?.code ?? null;
+ }
+ };
const allTools = [
'update_agent_plan',
'respond_to_user',
- 'runtime_tool_agent_delegate',
+ 'runtime_tool_agent_action_history',
+ 'runtime_tool_agent_acceptance_update',
'runtime_tool_agent_run_status',
'runtime_tool_command_run_limited',
'runtime_tool_file_patch',
@@ -405,141 +461,155 @@ async function runSelfTest() {
'runtime_tool_asset_list',
'runtime_tool_file_read',
];
+ const rootRunId = 'parent-run';
+ const contractFingerprint = 'a'.repeat(64);
+ const contractContext = `[Root Goal Contract]\n${JSON.stringify({ contractFingerprint })}`;
+ const observationContext = (observations) =>
+ `${contractContext}\n已有工具观察:\n${JSON.stringify(observations)}`;
+ const goalContractTools = ['runtime_tool_agent_goal_contract'];
+ const goalContractRouter = createDeterministicLaneDefenseRouter({ apiKey });
+ goalContractRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'goal-contract-order-run',
+ goalContractTools,
+ ),
+ });
+ assert(
+ captureProviderErrorCode(() =>
+ goalContractRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'goal-contract-order-run',
+ goalContractTools,
+ ),
+ }),
+ ) === 'provider-goal-contract-out-of-order',
+ 'self-test-goal-contract-duplicate-not-rejected',
+ );
+
+ const router = createDeterministicLaneDefenseRouter({ apiKey });
const route = (agentId, runId, tools = allTools, extraContext = '') =>
router.route({
authorization: `Bearer ${apiKey}`,
payload: syntheticPayload(agentId, runId, tools, extraContext),
});
- const initialDelegationResponse = route('project-supervisor', 'parent-run');
- const initialDelegationCalls = responseFunctionCalls(
- initialDelegationResponse,
+ const interactionExecuteResponse = router.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticInteractionExecutePayload(),
+ });
+ const interactionExecuteCalls = responseFunctionCalls(
+ interactionExecuteResponse,
+ );
+ const interactionExecuteStats = router.getStats();
+ assert(
+ interactionExecuteCalls.length === 1 &&
+ interactionExecuteCalls[0]?.name === 'runtime_execute' &&
+ Object.keys(interactionExecuteCalls[0]?.arguments ?? {}).length === 0 &&
+ interactionExecuteStats.requestCount === 1 &&
+ interactionExecuteStats.planningRequestCount === 1 &&
+ interactionExecuteStats.interactionExecuteCount === 1 &&
+ interactionExecuteStats.manifestReadyTaskRunCount === 0 &&
+ interactionExecuteStats.manifestReadyTaskCompletionCount === 0 &&
+ Object.keys(interactionExecuteStats.readyTaskCountsByAgent).length === 0,
+ 'self-test-interaction-max-execute-sequence-invalid',
+ );
+ const visualInspectionResponse = router.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticUiPrototypeInspectionPayload(),
+ });
+ const visualInspection = JSON.parse(
+ visualInspectionResponse.choices[0].message.content,
);
assert(
- initialDelegationCalls.map((call) => call.name).join(',') ===
- 'runtime_tool_agent_delegate,runtime_tool_agent_delegate,runtime_tool_agent_delegate',
- 'self-test-initial-delegation-invalid',
+ Object.keys(visualInspection?.checks ?? {}).join(',') ===
+ 'informationHud,gameplaySurface,objectiveEntities,primaryControls,failureRestartFlow,responsiveLayout,implementationClarity,originalTheme' &&
+ Object.values(visualInspection.checks).every((value) => value === true) &&
+ Array.isArray(visualInspection.issues) &&
+ visualInspection.issues.length === 0 &&
+ router.getStats().imageInspectionRequestCount === 1 &&
+ router.getStats().unexpectedRequestCount === 0,
+ 'self-test-ui-prototype-inspection-request-not-routed',
);
- const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input;
- const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input;
- const initialArtDelegation = initialDelegationCalls[2]?.arguments?.input;
- assert(
- initialCodeDelegation?.agentId === 'code-prototype' &&
- JSON.stringify(initialCodeDelegation.expectedArtifacts) ===
- JSON.stringify(['game/index.html']) &&
- initialQualityDelegation?.agentId === 'quality-review' &&
- initialQualityDelegation.task.includes('只读验收') &&
- initialQualityDelegation.task.includes('不要修改任何文件') &&
- initialQualityDelegation.task.includes(
- '不要读取或依赖并行 code-prototype',
- ) &&
- Array.isArray(initialQualityDelegation.expectedArtifacts) &&
- initialQualityDelegation.expectedArtifacts.length === 0 &&
- initialArtDelegation?.agentId === 'art-asset-plan' &&
- initialArtDelegation.task.includes('canvas.asset_generate') &&
- JSON.stringify(initialArtDelegation.expectedArtifacts) ===
- JSON.stringify([
- 'assets/manifest.art.json',
- 'assets/art-spritesheet.png',
- ]),
- 'self-test-initial-delegation-contract-invalid',
- );
- const qualityPlan =
- '计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无';
- const builderPlan =
- '计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无';
- assert(
- responseFunctionNames(
- route('quality-review', 'quality-run', allTools, qualityPlan),
- ).join(',') === 'update_agent_plan,respond_to_user',
- 'self-test-quality-read-only-invalid',
- );
- route('code-prototype', 'initial-code-run');
- route('code-prototype', 'initial-code-run');
- assert(
- responseFunctionNames(
- route('code-prototype', 'initial-code-run', allTools, builderPlan),
- ).join(',') === 'update_agent_plan,respond_to_user',
- 'self-test-builder-plan-completion-invalid',
- );
- assert(
- responseFunctionNames(
- route(
- 'quality-review',
- 'quality-run',
- allTools,
- `${qualityPlan}\n已有工具观察:\n${JSON.stringify([
- {
- tool: 'runtime.verification',
- status: 'blocked',
- summary: '最终回复基于的项目 revision 已过期',
- detail: 'responseRevision=0, currentRevision=1',
- },
- ])}`,
- ),
- ).join(',') === 'update_agent_plan,respond_to_user',
- 'self-test-quality-stale-replan-invalid',
- );
- route('project-supervisor', 'parent-run');
- route('project-supervisor', 'parent-run');
- route('project-supervisor', 'parent-run');
- assert(
- responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
- 'runtime_tool_file_patch',
- 'self-test-forbidden-parent-mutation-invalid',
- );
- const followupDelegationResponse = route(
+ const goalContractResponse = route(
'project-supervisor',
- 'parent-run',
- ['runtime_tool_agent_delegate'],
- '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate',
+ rootRunId,
+ goalContractTools,
);
- const followupDelegationCall = responseFunctionCalls(
- followupDelegationResponse,
- )[0];
+ const goalContractCalls = responseFunctionCalls(goalContractResponse);
+ const acceptanceNodes =
+ goalContractCalls[0]?.arguments?.input?.acceptanceNodes ?? [];
assert(
- followupDelegationCall?.name === 'runtime_tool_agent_delegate' &&
- followupDelegationCall.arguments?.input?.agentId === 'code-prototype' &&
- JSON.stringify(
- followupDelegationCall.arguments.input.expectedArtifacts,
- ) === JSON.stringify(['game/index.html']),
- 'self-test-followup-delegation-invalid',
- );
- route('code-prototype', 'repair-code-run');
- route('code-prototype', 'repair-code-run');
- route('code-prototype', 'repair-code-run', allTools, builderPlan);
- assert(
- responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
- 'runtime_tool_agent_run_status',
- 'self-test-repair-claim-before-verification-invalid',
- );
- assert(
- responseFunctionNames(route('project-supervisor', 'parent-run')).join(
- ',',
- ) === 'runtime_tool_task_list,runtime_tool_agent_run_status',
- 'self-test-manifest-wait-invalid',
+ goalContractCalls.length === 1 &&
+ goalContractCalls[0]?.name === 'runtime_tool_agent_goal_contract' &&
+ goalContractCalls[0]?.arguments?.input?.outcome?.includes(
+ '可完整游玩的原创植物塔防网页游戏',
+ ) &&
+ acceptanceNodes.length === 2 &&
+ acceptanceNodes[0]?.criterionId === 'static-current-revision' &&
+ JSON.stringify(acceptanceNodes[0]?.requiredEvidence) ===
+ JSON.stringify(['tool:command.run_limited']) &&
+ acceptanceNodes[1]?.criterionId === 'playable-current-revision' &&
+ JSON.stringify(acceptanceNodes[1]?.requiredEvidence) ===
+ JSON.stringify(['tool:preview.validate']) &&
+ JSON.stringify(acceptanceNodes[1]?.dependsOn) ===
+ JSON.stringify(['static-current-revision']),
+ 'self-test-goal-contract-invalid',
);
assert(
responseFunctionNames(
route(
'project-supervisor',
- 'parent-run',
- allTools,
- '已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16',
+ rootRunId,
+ ['runtime_tool_task_list', 'runtime_tool_agent_run_status'],
+ `${contractContext}\n当前正式 manifest DAG 仍有专业 task 在运行`,
),
- ).join(',') ===
- 'runtime_tool_command_run_limited,runtime_tool_preview_validate',
- 'self-test-repair-verification-batch-invalid',
+ ).join(',') === 'runtime_tool_task_list,runtime_tool_agent_run_status',
+ 'self-test-manifest-wait-invalid',
);
- route('project-supervisor', 'parent-run');
+ const visualReadyAgentIds = new Set([
+ 'art-director',
+ 'design-foundation',
+ 'art-asset-plan',
+ ]);
+ const canvasCallsByAgent = new Map();
+ const callBatchesByAgent = new Map();
for (const agentId of deterministicManifestReadyAgentIds) {
const runId = `manifest-ready-${agentId}`;
const extraContext =
`处理 manifest ready 任务:${agentId}\n` + `任务 ID:${agentId}`;
+ const readyTools = visualReadyAgentIds.has(agentId)
+ ? [
+ ...manifestReadyTools,
+ 'runtime_tool_canvas_asset_generate',
+ 'runtime_tool_image_inspect',
+ ]
+ : manifestReadyTools;
let terminalCompletionCount = 0;
for (let attempt = 0; attempt < 8; attempt += 1) {
- const response = route(agentId, runId, manifestReadyTools, extraContext);
- if (responseFunctionNames(response).includes('respond_to_user')) {
+ const attemptTools =
+ agentId === 'art-director' && attempt >= 2
+ ? ['respond_to_user']
+ : readyTools;
+ const response = route(agentId, runId, attemptTools, extraContext);
+ const calls = responseFunctionCalls(response);
+ const batches = callBatchesByAgent.get(agentId) ?? [];
+ batches.push(calls.map((call) => call.name));
+ callBatchesByAgent.set(agentId, batches);
+ const canvasCall = calls.find(
+ (call) => call.name === 'runtime_tool_canvas_asset_generate',
+ );
+ if (canvasCall) {
+ assert(
+ !canvasCallsByAgent.has(agentId),
+ `self-test-canvas-generation-duplicate:${agentId}`,
+ );
+ canvasCallsByAgent.set(agentId, canvasCall);
+ }
+ if (calls.some((call) => call.name === 'respond_to_user')) {
terminalCompletionCount += 1;
break;
}
@@ -549,10 +619,429 @@ async function runSelfTest() {
`self-test-manifest-ready-terminal-invalid:${agentId}`,
);
}
+ const artDirectorAssetCall = canvasCallsByAgent.get('art-director');
+ const designFoundationAssetCall = canvasCallsByAgent.get('design-foundation');
+ const artAssetPlanAssetCall = canvasCallsByAgent.get('art-asset-plan');
+ assert(
+ JSON.stringify(callBatchesByAgent.get('art-asset-plan')) ===
+ JSON.stringify([
+ ['runtime_tool_asset_list'],
+ ['runtime_tool_file_write'],
+ ['runtime_tool_canvas_asset_generate'],
+ ['runtime_tool_file_read', 'runtime_tool_asset_list'],
+ ['update_agent_plan', 'respond_to_user'],
+ ]),
+ 'self-test-art-asset-plan-revision-stages-invalid',
+ );
+ assert(
+ JSON.stringify(callBatchesByAgent.get('code-prototype')) ===
+ JSON.stringify([
+ [
+ 'runtime_tool_file_read',
+ 'runtime_tool_file_read',
+ 'runtime_tool_file_read',
+ ],
+ [
+ 'runtime_tool_file_read',
+ 'runtime_tool_file_read',
+ 'runtime_tool_file_read',
+ 'runtime_tool_asset_list',
+ ],
+ ['runtime_tool_file_write'],
+ ['runtime_tool_command_run_limited'],
+ ['update_agent_plan', 'respond_to_user'],
+ ]),
+ 'self-test-code-prototype-asset-audit-sequence-invalid',
+ );
+ assert(
+ canvasCallsByAgent.size === 3 &&
+ artDirectorAssetCall?.arguments?.input?.outputPath ===
+ 'assets/art-spec.png' &&
+ artDirectorAssetCall.arguments.input.aspectRatio === '1:1' &&
+ artDirectorAssetCall.arguments.input.assetKind === 'icon-spec' &&
+ designFoundationAssetCall?.arguments?.input?.outputPath ===
+ 'assets/ui-prototype.png' &&
+ designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
+ designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
+ artAssetPlanAssetCall?.arguments?.input?.outputPath ===
+ 'assets/art-spritesheet.png' &&
+ artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
+ artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
+ 'self-test-visual-assets-invalid',
+ );
+
+ const visualRecoveryRouter = createDeterministicLaneDefenseRouter({ apiKey });
+ const visualRecoveryRunId = 'visual-recovery-design-foundation';
+ const visualRecoveryTools = [
+ ...manifestReadyTools,
+ 'runtime_tool_canvas_asset_generate',
+ 'runtime_tool_image_inspect',
+ ];
+ const visualRecoveryRoute = (
+ tools = visualRecoveryTools,
+ extraContext = '',
+ ) =>
+ visualRecoveryRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'design-foundation',
+ visualRecoveryRunId,
+ tools,
+ `处理 manifest ready 任务:design-foundation\n任务 ID:design-foundation${extraContext}`,
+ ),
+ });
+ visualRecoveryRoute();
+ visualRecoveryRoute();
+ const visualRecoveryCanvasResponse = visualRecoveryRoute();
+ const visualRecoveryInitialInspectResponse = visualRecoveryRoute();
+ assert(
+ responseFunctionNames(visualRecoveryCanvasResponse).join(',') ===
+ 'runtime_tool_canvas_asset_generate' &&
+ responseFunctionNames(visualRecoveryInitialInspectResponse).includes(
+ 'runtime_tool_image_inspect',
+ ),
+ 'self-test-ui-prototype-initial-inspection-sequence-invalid',
+ );
+ const failedInspectObservation = {
+ tool: 'image.inspect',
+ status: 'failed',
+ summary: '视觉模型调用失败',
+ detail: 'upstream-422',
+ };
+ const prematureCompletionResponse = visualRecoveryRoute(
+ ['update_agent_plan', 'respond_to_user'],
+ `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`,
+ );
+ assert(
+ responseFunctionNames(prematureCompletionResponse).includes(
+ 'respond_to_user',
+ ),
+ 'self-test-ui-prototype-premature-completion-fixture-invalid',
+ );
+ const visualBlockedObservation = {
+ tool: 'runtime.visual_asset',
+ status: 'blocked',
+ summary: 'UI 原型视觉检查尚未通过',
+ detail:
+ 'expectedPath=assets/ui-prototype.png · requiredInspection=image.inspect',
+ };
+ const blockedRetryResponse = visualRecoveryRoute(
+ visualRecoveryTools,
+ `\n已有工具观察:\n${JSON.stringify([visualBlockedObservation])}`,
+ );
+ assert(
+ responseFunctionNames(blockedRetryResponse).join(',') ===
+ 'runtime_tool_image_inspect',
+ 'self-test-ui-prototype-visual-block-did-not-retry-inspection',
+ );
+ const failedRetryResponse = visualRecoveryRoute(
+ visualRecoveryTools,
+ `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`,
+ );
+ assert(
+ responseFunctionNames(failedRetryResponse).join(',') ===
+ 'runtime_tool_image_inspect',
+ 'self-test-ui-prototype-failed-inspection-did-not-retry',
+ );
+ const successfulInspectObservation = {
+ tool: 'image.inspect',
+ status: 'ok',
+ summary: '视觉检查已完成,共分析 1 张图片',
+ detail: null,
+ };
+ const visualRecoveryFinalResponse = visualRecoveryRoute(
+ ['update_agent_plan', 'respond_to_user'],
+ `\n已有工具观察:\n${JSON.stringify([successfulInspectObservation])}`,
+ );
+ const visualRecoveryStats = visualRecoveryRouter.getStats();
+ assert(
+ responseFunctionNames(visualRecoveryFinalResponse).includes(
+ 'respond_to_user',
+ ) &&
+ visualRecoveryStats.manifestReadyTaskRunCount === 1 &&
+ visualRecoveryStats.manifestReadyTaskCompletionCount === 1 &&
+ visualRecoveryStats.manifestReadyTaskCanvasGenerationCount === 1 &&
+ visualRecoveryStats.byAgent?.['design-foundation']?.projectMutation ===
+ 3 &&
+ visualRecoveryStats.unexpectedRequestCount === 0,
+ 'self-test-ui-prototype-visual-recovery-not-exactly-once',
+ );
+
+ const finalStaticResponse = route(
+ 'project-supervisor',
+ rootRunId,
+ ['runtime_tool_command_run_limited'],
+ `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`,
+ );
+ assert(
+ responseFunctionNames(finalStaticResponse).join(',') ===
+ 'runtime_tool_command_run_limited',
+ 'self-test-final-static-not-exclusive',
+ );
+ const successfulFinalStaticObservation = {
+ tool: 'command.run_limited',
+ status: 'ok',
+ summary: 'game.static_smoke 已完成',
+ detail: null,
+ };
+ const finalPreviewResponse = route(
+ 'project-supervisor',
+ rootRunId,
+ ['runtime_tool_preview_validate'],
+ observationContext([successfulFinalStaticObservation]),
+ );
+ assert(
+ responseFunctionNames(finalPreviewResponse).join(',') ===
+ 'runtime_tool_preview_validate',
+ 'self-test-final-preview-not-exclusive',
+ );
+ const successfulFinalPreviewObservation = {
+ tool: 'preview.validate',
+ status: 'ok',
+ summary: '桌面与移动视口真实试玩已通过',
+ detail: null,
+ };
+ const finalHistoryResponse = route(
+ 'project-supervisor',
+ rootRunId,
+ ['runtime_tool_agent_action_history'],
+ observationContext([successfulFinalPreviewObservation]),
+ );
+ const finalHistoryCalls = responseFunctionCalls(finalHistoryResponse);
+ assert(
+ finalHistoryCalls.length === 1 &&
+ finalHistoryCalls[0]?.name === 'runtime_tool_agent_action_history' &&
+ JSON.stringify(finalHistoryCalls[0]?.arguments?.input) ===
+ JSON.stringify({
+ runId: null,
+ actionId: null,
+ tool: null,
+ status: 'ok',
+ limit: 10,
+ }),
+ 'self-test-final-action-history-invalid',
+ );
+ const staticActionId = `action-${'1'.repeat(24)}`;
+ const previewActionId = `action-${'2'.repeat(24)}`;
+ const actionHistoryDetail = JSON.stringify({
+ runId: rootRunId,
+ count: 2,
+ truncated: false,
+ outputTruncated: false,
+ actions: [
+ {
+ agentId: 'project-supervisor',
+ runId: rootRunId,
+ actionId: staticActionId,
+ tool: 'command.run_limited',
+ status: 'ok',
+ },
+ {
+ agentId: 'project-supervisor',
+ runId: rootRunId,
+ actionId: previewActionId,
+ tool: 'preview.validate',
+ status: 'ok',
+ },
+ ],
+ });
+ const finalAcceptanceResponse = route(
+ 'project-supervisor',
+ rootRunId,
+ ['runtime_tool_agent_acceptance_update'],
+ observationContext([
+ {
+ tool: 'agent.action_history',
+ status: 'ok',
+ summary: '已读取当前 Agent 的 2 条终态动作',
+ detail: actionHistoryDetail,
+ },
+ ]),
+ );
+ const finalAcceptanceCalls = responseFunctionCalls(finalAcceptanceResponse);
+ const acceptanceInput = finalAcceptanceCalls[0]?.arguments?.input;
+ assert(
+ finalAcceptanceCalls.length === 1 &&
+ finalAcceptanceCalls[0]?.name ===
+ 'runtime_tool_agent_acceptance_update' &&
+ acceptanceInput?.contractFingerprint === contractFingerprint &&
+ acceptanceInput?.evaluations?.length === 2 &&
+ acceptanceInput.evaluations[0]?.criterionId ===
+ 'static-current-revision' &&
+ acceptanceInput.evaluations[0]?.evidence?.[0]?.actionId ===
+ staticActionId &&
+ acceptanceInput.evaluations[1]?.criterionId ===
+ 'playable-current-revision' &&
+ acceptanceInput.evaluations[1]?.evidence?.[0]?.actionId ===
+ previewActionId,
+ 'self-test-final-acceptance-update-invalid',
+ );
+ const finalRespondResponse = route(
+ 'project-supervisor',
+ rootRunId,
+ ['respond_to_user'],
+ observationContext([
+ {
+ tool: 'agent.acceptance_update',
+ status: 'ok',
+ summary: 'Acceptance Graph 已更新',
+ detail: null,
+ },
+ ]),
+ );
+ assert(
+ responseFunctionNames(finalRespondResponse).join(',') === 'respond_to_user',
+ 'self-test-final-respond-not-exclusive',
+ );
+
+ const planRejectionRouter = createDeterministicLaneDefenseRouter({ apiKey });
+ planRejectionRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'plan-rejection-run',
+ goalContractTools,
+ ),
+ });
+ const planRejectionResponse = planRejectionRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'plan-rejection-run',
+ ['respond_to_user', 'runtime_tool_file_patch', 'runtime_tool_file_write'],
+ '当前父 run 已进入只编排模式;本轮只允许收束或安全编排。',
+ ),
+ });
+ const planRejectionWaitResponse = planRejectionRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'plan-rejection-run',
+ ['runtime_tool_task_list', 'runtime_tool_agent_run_status'],
+ '当前正式 manifest DAG 仍有专业 task 在运行',
+ ),
+ });
+ const planRejectionStats = planRejectionRouter.getStats();
+ assert(
+ responseFunctionNames(planRejectionResponse).join(',') ===
+ 'respond_to_user' &&
+ responseFunctionNames(planRejectionWaitResponse).join(',') ===
+ 'runtime_tool_task_list,runtime_tool_agent_run_status' &&
+ planRejectionStats.byAgent?.['project-supervisor']?.projectMutation ===
+ 0 &&
+ planRejectionStats.delegateActionCount === 0 &&
+ planRejectionStats.runStatusCount === 1 &&
+ planRejectionStats.unexpectedRequestCount === 0,
+ 'self-test-supervisor-plan-rejection-did-not-converge-read-only',
+ );
+
+ const earlyFinalReplyRouter = createDeterministicLaneDefenseRouter({
+ apiKey,
+ });
+ assert(
+ captureProviderErrorCode(() =>
+ earlyFinalReplyRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ 'early-final-reply-run',
+ [],
+ '给用户一个正常中文回复',
+ ),
+ }),
+ ) === 'provider-parent-final-reply-before-acceptance',
+ 'self-test-parent-final-reply-bypassed-acceptance',
+ );
+
+ const acceptanceGuardRunId = 'acceptance-guard-run';
+ const acceptanceGuardRouter = createDeterministicLaneDefenseRouter({
+ apiKey,
+ });
+ const acceptanceGuardRoute = (tools, extraContext = '') =>
+ acceptanceGuardRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ 'project-supervisor',
+ acceptanceGuardRunId,
+ tools,
+ extraContext,
+ ),
+ });
+ acceptanceGuardRoute(goalContractTools);
+ acceptanceGuardRoute(
+ ['runtime_tool_command_run_limited'],
+ `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`,
+ );
+ acceptanceGuardRoute(
+ ['runtime_tool_preview_validate'],
+ observationContext([successfulFinalStaticObservation]),
+ );
+ acceptanceGuardRoute(
+ ['runtime_tool_agent_action_history'],
+ observationContext([successfulFinalPreviewObservation]),
+ );
+ const acceptanceGuardDetail = JSON.parse(actionHistoryDetail);
+ acceptanceGuardDetail.runId = acceptanceGuardRunId;
+ for (const action of acceptanceGuardDetail.actions) {
+ action.runId = acceptanceGuardRunId;
+ }
+ const actionHistoryObservationFor = (detail) => ({
+ tool: 'agent.action_history',
+ status: 'ok',
+ summary: '已读取当前 Agent 的 2 条终态动作',
+ detail: JSON.stringify(detail),
+ });
+ const ambiguousContractContext = `[Root Goal Contract]\n{"contractFingerprint":"${contractFingerprint}","nested":{"contractFingerprint":"${'b'.repeat(64)}"}}`;
+ assert(
+ captureProviderErrorCode(() =>
+ acceptanceGuardRoute(
+ ['runtime_tool_agent_acceptance_update'],
+ `${ambiguousContractContext}\n已有工具观察:\n${JSON.stringify([
+ actionHistoryObservationFor(acceptanceGuardDetail),
+ ])}`,
+ ),
+ ) === 'provider-goal-contract-fingerprint-missing',
+ 'self-test-ambiguous-goal-contract-fingerprint-accepted',
+ );
+ const invalidActionHistoryDetail = structuredClone(acceptanceGuardDetail);
+ invalidActionHistoryDetail.actions[0].actionId = 'action-invalid';
+ assert(
+ captureProviderErrorCode(() =>
+ acceptanceGuardRoute(
+ ['runtime_tool_agent_acceptance_update'],
+ `${contractContext}\n已有工具观察:\n${JSON.stringify([
+ actionHistoryObservationFor(invalidActionHistoryDetail),
+ ])}`,
+ ),
+ ) === 'provider-final-acceptance-evidence-missing',
+ 'self-test-invalid-action-history-id-accepted',
+ );
+ assert(
+ responseFunctionNames(
+ acceptanceGuardRoute(
+ ['runtime_tool_agent_acceptance_update'],
+ `${contractContext}\n已有工具观察:\n${JSON.stringify([
+ actionHistoryObservationFor(acceptanceGuardDetail),
+ ])}`,
+ ),
+ ).join(',') === 'runtime_tool_agent_acceptance_update',
+ 'self-test-acceptance-stage-advanced-after-rejected-history',
+ );
+
const stats = router.getStats();
const manifestReadyTasks = manifestReadyTaskEvidence(stats);
assert(
- manifestReadyTasks.exactlyOnce,
+ manifestReadyTasks.exactlyOnce &&
+ stats.goalContractCount === 1 &&
+ stats.actionHistoryCount === 1 &&
+ stats.acceptanceUpdateCount === 1 &&
+ stats.interactionExecuteCount === 1 &&
+ stats.delegateActionCount === 0 &&
+ stats.imageInspectionRequestCount === 1 &&
+ stats.manifestReadyTaskCanvasGenerationCount === 3 &&
+ stats.byAgent?.['project-supervisor']?.projectMutation === 0 &&
+ stats.byAgent?.['code-prototype']?.projectMutation === 1 &&
+ stats.byAgent?.['quality-review']?.projectMutation === 0,
'self-test-manifest-ready-exactly-once-invalid',
);
@@ -597,6 +1086,20 @@ async function runSelfTest() {
detail:
'repositoryContextDrift=true · 请在同一 run 下一轮 planning 重新确认适用规范',
};
+ const projectLockSmokeObservations = [
+ {
+ tool: 'command.run_limited',
+ status: 'failed',
+ summary: 'game.static_smoke 无法取得项目验证锁',
+ detail: '项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock',
+ },
+ {
+ tool: 'command.run_limited',
+ status: 'failed',
+ summary: 'game.static_smoke 无法取得项目验证锁',
+ detail: '项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock',
+ },
+ ];
const projectRevisionDriftSmokeObservation = {
tool: 'command.run_limited',
status: 'blocked',
@@ -604,43 +1107,179 @@ async function runSelfTest() {
detail:
'projectRevisionDrift=true · expectedRevision=4 · currentRevision=5 · replanRequired=true',
};
+ const projectRevisionDriftCanvasObservation = {
+ tool: 'canvas.asset_generate',
+ status: 'blocked',
+ summary: '并行项目变更使旧动作过期,旧动作未执行',
+ detail:
+ 'projectRevisionDrift=true · expectedRevision=5 · currentRevision=6 · replanRequired=true',
+ };
const successfulProjectMutationObservation = {
tool: 'project.patchset',
status: 'ok',
summary: 'project.patchset 已原子应用 2 项变更',
detail: 'checkpointId=checkpoint-concurrent-ready-batch',
};
- const captureProviderErrorCode = (operation) => {
- try {
- operation();
- return null;
- } catch (error) {
- return error?.code ?? null;
- }
+ const missingArtAssetObservation = {
+ tool: 'runtime.visual_asset',
+ status: 'blocked',
+ summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
+ detail: '[redacted sensitive context]',
+ };
+ const successfulCanvasMutationObservation = {
+ tool: 'canvas.asset_generate',
+ status: 'ok',
+ summary: '素材画布图片已生成并登记',
+ detail: null,
};
- const oldSmokeRouter = createDeterministicLaneDefenseRouter({ apiKey });
- const oldSmokeRoute = (extraContext = '') =>
- oldSmokeRouter.route({
+ const artAssetRecoveryAgentId = 'art-asset-plan';
+ const artAssetRecoveryRunId = 'art-asset-visual-recovery-ready-run';
+ const artAssetRecoveryTools = [
+ ...manifestReadyTools,
+ 'runtime_tool_canvas_asset_generate',
+ ];
+ const artAssetRecoveryRouter = createDeterministicLaneDefenseRouter({
+ apiKey,
+ });
+ const artAssetRecoveryRoute = (extraContext = '') =>
+ artAssetRecoveryRouter.route({
authorization: `Bearer ${apiKey}`,
payload: syntheticPayload(
- 'code-prototype',
- 'old-smoke-initial-code-run',
- allTools,
- extraContext,
+ artAssetRecoveryAgentId,
+ artAssetRecoveryRunId,
+ artAssetRecoveryTools,
+ `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`,
),
});
- oldSmokeRoute();
- oldSmokeRoute();
- oldSmokeRoute();
+ for (let attempt = 0; attempt < 8; attempt += 1) {
+ if (
+ responseFunctionNames(artAssetRecoveryRoute()).includes('respond_to_user')
+ ) {
+ break;
+ }
+ }
+ const artAssetRegenerationResponse = artAssetRecoveryRoute(
+ retryObservationContext([missingArtAssetObservation]),
+ );
+ const artAssetReverificationResponse = artAssetRecoveryRoute(
+ retryObservationContext([successfulCanvasMutationObservation]),
+ );
+ const artAssetRefinalizationResponse = artAssetRecoveryRoute(
+ retryObservationContext([successfulSmokeObservation]),
+ );
+ const artAssetRecoveryStats = artAssetRecoveryRouter.getStats();
assert(
- responseFunctionNames(
- oldSmokeRoute(retryObservationContext([successfulSmokeObservation])),
- ).includes('respond_to_user') &&
- captureProviderErrorCode(() =>
- oldSmokeRoute(retryObservationContext([successfulSmokeObservation])),
- ) === 'provider-terminal-replay-unarmed:code-prototype',
- 'self-test-initial-terminal-old-smoke-replayed',
+ responseFunctionNames(artAssetRegenerationResponse).join(',') ===
+ 'runtime_tool_canvas_asset_generate' &&
+ responseFunctionNames(artAssetReverificationResponse).join(',') ===
+ 'runtime_tool_command_run_limited' &&
+ responseFunctionNames(artAssetRefinalizationResponse).includes(
+ 'respond_to_user',
+ ) &&
+ artAssetRecoveryStats.readyTaskCountsByAgent[artAssetRecoveryAgentId]
+ ?.completion === 1 &&
+ artAssetRecoveryStats.manifestReadyTaskCanvasGenerationCount === 2 &&
+ artAssetRecoveryStats.unexpectedRequestCount === 0,
+ 'self-test-art-asset-visual-recovery-not-exactly-once',
+ );
+
+ const artAssetRevisionDriftRunId = 'art-asset-revision-drift-ready-run';
+ const artAssetRevisionDriftRouter = createDeterministicLaneDefenseRouter({
+ apiKey,
+ });
+ const artAssetRevisionDriftRoute = (extraContext = '') =>
+ artAssetRevisionDriftRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ artAssetRecoveryAgentId,
+ artAssetRevisionDriftRunId,
+ artAssetRecoveryTools,
+ `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`,
+ ),
+ });
+ const artAssetInitialListResponse = artAssetRevisionDriftRoute();
+ const artAssetManifestWriteResponse = artAssetRevisionDriftRoute();
+ const artAssetInitialCanvasResponse = artAssetRevisionDriftRoute();
+ const artAssetRetryCanvasResponse = artAssetRevisionDriftRoute(
+ retryObservationContext([projectRevisionDriftCanvasObservation]),
+ );
+ const artAssetPostRetryReadResponse = artAssetRevisionDriftRoute(
+ retryObservationContext([successfulCanvasMutationObservation]),
+ );
+ const artAssetRevisionDriftFinalizationResponse =
+ artAssetRevisionDriftRoute();
+ const artAssetRevisionDriftStats = artAssetRevisionDriftRouter.getStats();
+ assert(
+ responseFunctionNames(artAssetInitialListResponse).join(',') ===
+ 'runtime_tool_asset_list' &&
+ responseFunctionNames(artAssetManifestWriteResponse).join(',') ===
+ 'runtime_tool_file_write' &&
+ responseFunctionNames(artAssetInitialCanvasResponse).join(',') ===
+ 'runtime_tool_canvas_asset_generate' &&
+ responseFunctionNames(artAssetRetryCanvasResponse).join(',') ===
+ 'runtime_tool_canvas_asset_generate' &&
+ responseFunctionNames(artAssetPostRetryReadResponse).join(',') ===
+ 'runtime_tool_file_read,runtime_tool_asset_list' &&
+ responseFunctionNames(artAssetRevisionDriftFinalizationResponse).includes(
+ 'respond_to_user',
+ ) &&
+ artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId]
+ ?.run === 1 &&
+ artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId]
+ ?.completion === 1 &&
+ artAssetRevisionDriftStats.manifestReadyTaskCanvasGenerationCount === 2 &&
+ artAssetRevisionDriftStats.unexpectedRequestCount === 0,
+ 'self-test-art-asset-revision-drift-recovery-not-exactly-once',
+ );
+
+ const artAssetRevisionDriftLimitRunId =
+ 'art-asset-revision-drift-limit-ready-run';
+ const artAssetRevisionDriftLimitRouter = createDeterministicLaneDefenseRouter(
+ { apiKey },
+ );
+ const artAssetRevisionDriftLimitRoute = (extraContext = '') =>
+ artAssetRevisionDriftLimitRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ artAssetRecoveryAgentId,
+ artAssetRevisionDriftLimitRunId,
+ artAssetRecoveryTools,
+ `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`,
+ ),
+ });
+ artAssetRevisionDriftLimitRoute();
+ artAssetRevisionDriftLimitRoute();
+ artAssetRevisionDriftLimitRoute();
+ for (let retry = 0; retry < 16; retry += 1) {
+ assert(
+ responseFunctionNames(
+ artAssetRevisionDriftLimitRoute(
+ retryObservationContext([projectRevisionDriftCanvasObservation]),
+ ),
+ ).join(',') === 'runtime_tool_canvas_asset_generate',
+ `self-test-art-asset-revision-drift-retry-invalid:${retry}`,
+ );
+ }
+ const artAssetRevisionDriftLimitCode = captureProviderErrorCode(() =>
+ artAssetRevisionDriftLimitRoute(
+ retryObservationContext([projectRevisionDriftCanvasObservation]),
+ ),
+ );
+ const artAssetRevisionDriftLimitStats =
+ artAssetRevisionDriftLimitRouter.getStats();
+ assert(
+ artAssetRevisionDriftLimitCode ===
+ 'provider-ready-art-canvas-retry-exhausted' &&
+ artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[
+ artAssetRecoveryAgentId
+ ]?.run === 1 &&
+ artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[
+ artAssetRecoveryAgentId
+ ]?.completion === 0 &&
+ artAssetRevisionDriftLimitStats.manifestReadyTaskCanvasGenerationCount ===
+ 17,
+ 'self-test-art-asset-revision-drift-limit-invalid',
);
const readOnlyCommandRouter = createDeterministicLaneDefenseRouter({
@@ -713,58 +1352,30 @@ async function runSelfTest() {
'self-test-ready-precompletion-limit-invalid',
);
- const exerciseInitialTerminalRetryLimit = ({
- agentId,
- runId,
- tools,
- initialRequestCount,
- }) => {
- const terminalRouter = createDeterministicLaneDefenseRouter({ apiKey });
- const terminalRoute = (extraContext = '') =>
- terminalRouter.route({
- authorization: `Bearer ${apiKey}`,
- payload: syntheticPayload(agentId, runId, tools, extraContext),
- });
- for (let request = 0; request < initialRequestCount; request += 1) {
- terminalRoute();
- }
- let deliveryCount = 0;
- for (let retry = 0; retry < 16; retry += 1) {
- assert(
- responseFunctionNames(
- terminalRoute(retryObservationContext([staleObservation])),
- ).join(',') === 'runtime_tool_command_run_limited',
- `self-test-initial-terminal-verification-invalid:${agentId}:${retry}`,
- );
- if (
- responseFunctionNames(
- terminalRoute(retryObservationContext([successfulSmokeObservation])),
- ).includes('respond_to_user')
- ) {
- deliveryCount += 1;
- }
- }
- const limitCode = captureProviderErrorCode(() =>
- terminalRoute(retryObservationContext([staleObservation])),
- );
+ const projectLockRouter = createDeterministicLaneDefenseRouter({ apiKey });
+ const projectLockRunId = 'project-lock-transient-ready-run';
+ const projectLockRoute = (tools, extraContext = '') =>
+ projectLockRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ preCompletionAgentId,
+ projectLockRunId,
+ tools,
+ `处理 manifest ready 任务:${preCompletionAgentId}\n任务 ID:${preCompletionAgentId}${extraContext}`,
+ ),
+ });
+ projectLockRoute(manifestReadyTools);
+ for (const observation of projectLockSmokeObservations) {
assert(
- deliveryCount === 16 &&
- limitCode === `provider-terminal-retry-exhausted:${agentId}`,
- `self-test-initial-terminal-retry-limit-invalid:${agentId}`,
+ responseFunctionNames(
+ projectLockRoute(
+ ['runtime_tool_command_run_limited'],
+ retryObservationContext([observation]),
+ ),
+ ).join(',') === 'runtime_tool_command_run_limited',
+ 'self-test-ready-project-lock-path-separator-not-retried',
);
- };
- exerciseInitialTerminalRetryLimit({
- agentId: 'code-prototype',
- runId: 'initial-code-terminal-retry-run',
- tools: allTools,
- initialRequestCount: 3,
- });
- exerciseInitialTerminalRetryLimit({
- agentId: 'art-asset-plan',
- runId: 'initial-art-terminal-retry-run',
- tools: [...manifestReadyTools, 'runtime_tool_canvas_asset_generate'],
- initialRequestCount: 5,
- });
+ }
const duplicateRouter = createDeterministicLaneDefenseRouter({ apiKey });
const duplicateRoute = (runId, extraContext = '') =>
@@ -857,6 +1468,49 @@ async function runSelfTest() {
'self-test-ready-retry-limit-or-exactly-once-invalid',
);
+ const verifiedDeliveryRepairRouter = createDeterministicLaneDefenseRouter({
+ apiKey,
+ });
+ const verifiedDeliveryRepairRunId = 'verified-delivery-repair-ready-run';
+ const verifiedDeliveryRepairRoute = (tools, extraContext = '') =>
+ verifiedDeliveryRepairRouter.route({
+ authorization: `Bearer ${apiKey}`,
+ payload: syntheticPayload(
+ retryAgentId,
+ verifiedDeliveryRepairRunId,
+ tools,
+ `处理 manifest ready 任务:${retryAgentId}\n任务 ID:${retryAgentId}${extraContext}`,
+ ),
+ });
+ for (let attempt = 0; attempt < 8; attempt += 1) {
+ if (
+ responseFunctionNames(
+ verifiedDeliveryRepairRoute(manifestReadyTools),
+ ).includes('respond_to_user')
+ ) {
+ break;
+ }
+ }
+ const rejectedVerificationPlan = verifiedDeliveryRepairRoute(
+ manifestReadyTools,
+ retryObservationContext([staleObservation]),
+ );
+ const verifiedDeliveryRepairResponse = verifiedDeliveryRepairRoute(
+ ['respond_to_user'],
+ `${retryObservationContext([staleObservation])}\n上一条输出不符合工具计划协议:当前 revision 已通过验证。本次修复的原生工具目录只保留 respond_to_user;必须立即调用它交付专业合同结论。`,
+ );
+ const verifiedDeliveryRepairStats = verifiedDeliveryRepairRouter.getStats();
+ assert(
+ responseFunctionNames(rejectedVerificationPlan).join(',') ===
+ 'runtime_tool_command_run_limited' &&
+ responseFunctionNames(verifiedDeliveryRepairResponse).join(',') ===
+ 'respond_to_user' &&
+ verifiedDeliveryRepairStats.readyTaskCountsByAgent[retryAgentId]
+ ?.completion === 1 &&
+ verifiedDeliveryRepairStats.unexpectedRequestCount === 0,
+ 'self-test-ready-verified-delivery-repair-invalid',
+ );
+
const transientRetryRouter = createDeterministicLaneDefenseRouter({ apiKey });
const transientRetryRunId = 'transient-retry-ready-run';
const transientRetryRoute = (extraContext = '') =>
@@ -1094,23 +1748,24 @@ async function runSelfTest() {
readOnlyRetryRouter.getStats().manifestReadyTaskStaticSmokeCount === 0,
'self-test-read-only-retry-used-forbidden-verification',
);
- const responsibilityContract = responsibilityContractEvidence(stats);
assert(
- responsibilityContract.codeOwnsGameIndex &&
- responsibilityContract.qualityIsReadOnly &&
- responsibilityContract.qualityIndependentOfCodeTiming &&
- responsibilityContract.artOwnsGeneratedAsset &&
- responsibilityContract.contractViolationFree,
- 'self-test-responsibility-contract-invalid',
- );
- assert(
- stats.initialDelegationCount === 3 &&
- stats.followupDelegationCount === 1 &&
- stats.supervisorDirectMutationAttemptCount === 1 &&
+ stats.goalContractCount === 1 &&
+ stats.actionHistoryCount === 1 &&
+ stats.acceptanceUpdateCount === 1 &&
+ stats.delegateActionCount === 0 &&
+ stats.manifestReadyTaskCanvasGenerationCount === 3 &&
+ stats.byAgent?.['project-supervisor']?.projectMutation === 0 &&
+ stats.byAgent?.['code-prototype']?.projectMutation === 1 &&
+ stats.byAgent?.['quality-review']?.projectMutation === 0 &&
stats.unexpectedRequestCount === 0,
'self-test-provider-core-stats-invalid',
);
+ const syntheticRuntimeProviderRequestCount =
+ stats.requestCount -
+ stats.interactionExecuteCount -
+ stats.imageInspectionRequestCount;
+
const syntheticChildReport = {
status: 'PASS',
suite,
@@ -1138,10 +1793,10 @@ async function runSelfTest() {
projectRevisionDelta: 2,
finalSupervisorAssistantCount: 1,
professionalAssistantCount: 3,
- providerRequestIdentityCount: stats.requestCount,
- providerLifecycleStartedCount: stats.requestCount,
- providerLifecycleTerminalCount: stats.requestCount,
- providerLifecycleCompletedCount: stats.requestCount,
+ providerRequestIdentityCount: syntheticRuntimeProviderRequestCount,
+ providerLifecycleStartedCount: syntheticRuntimeProviderRequestCount,
+ providerLifecycleTerminalCount: syntheticRuntimeProviderRequestCount,
+ providerLifecycleCompletedCount: syntheticRuntimeProviderRequestCount,
},
};
const incompletePlaytestReport = structuredClone(syntheticChildReport);
@@ -1195,14 +1850,39 @@ async function runSelfTest() {
run: duplicateRunCode,
},
terminalRetryContractsValidated: {
- oldSmokeObservationSingleUse: true,
readOnlyCommandOnlyRejected: true,
preCompletionTransientRetryLimit: 16,
- initialCodeRetryLimit: 16,
- initialArtRetryLimit: 16,
+ projectLockPathSeparatorsRetried: ['/', '\\'],
},
- responsibilityContract,
- qualityTimingOrdersValidated: ['before-code', 'after-code'],
+ providerRuntimeModeValidated: true,
+ interactionExecutionValidated: {
+ reasoningEffort: 'max',
+ requestCount: stats.interactionExecuteCount,
+ responseTools: interactionExecuteCalls.map((call) => call.name),
+ readyTaskRunCountBeforeRuntime:
+ interactionExecuteStats.manifestReadyTaskRunCount,
+ readyTaskCompletionCountBeforeRuntime:
+ interactionExecuteStats.manifestReadyTaskCompletionCount,
+ },
+ goalContractValidated: true,
+ finalizationSequenceValidated: [
+ 'command.run_limited',
+ 'preview.validate',
+ 'agent.action_history',
+ 'agent.acceptance_update',
+ 'respond_to_user',
+ ],
+ canvasArtifactsValidated: [
+ 'assets/art-spec.png',
+ 'assets/ui-prototype.png',
+ 'assets/art-spritesheet.png',
+ ],
+ uiPrototypeInspectionValidated: true,
+ uiPrototypeVisualRecoveryExactlyOnceValidated: true,
+ artAssetVisualRecoveryExactlyOnceValidated: true,
+ artAssetRevisionDriftRecoveryExactlyOnceValidated: true,
+ artAssetRevisionDriftRetryLimitValidated: 16,
+ supervisorPlanRejectionMode: 'respond-only-then-read-only-wait',
childHardGatesValidated: true,
packageCommandsRegistered: true,
};
@@ -1228,23 +1908,7 @@ async function runE2e(options) {
{ flag: 'wx', mode: 0o600 },
);
provider = await startDeterministicLaneDefenseProvider({ apiKey });
- const config = {
- editorApi: {
- apiKey,
- baseUrl: provider.editorBaseUrl,
- },
- llm: {
- apiKey,
- baseUrl: provider.baseUrl,
- model: deterministicLaneDefenseModel,
- apiKind: 'openai_chat',
- reasoningEffort: 'low',
- stream: false,
- requestTimeoutMs: 30_000,
- maxRetries: 0,
- retryBackoffMs: 100,
- },
- };
+ const config = deterministicRuntimeConfig(apiKey, provider);
await fs.writeFile(
path.join(configDir, configFileName),
`${JSON.stringify(config)}\n`,
@@ -1298,7 +1962,6 @@ async function runE2e(options) {
const providerPassed =
providerStats?.stopped === true && expectedProviderStats(providerStats);
const manifestReadyTasks = manifestReadyTaskEvidence(providerStats);
- const responsibilityContract = responsibilityContractEvidence(providerStats);
const status =
!failureCode && childPassed && providerPassed && configRemoved
? 'PASS'
@@ -1318,7 +1981,14 @@ async function runE2e(options) {
child: childReport,
provider: providerStats,
manifestReadyTasks,
- responsibilityContract,
+ finalizationContract: {
+ goalContractCount: providerStats?.goalContractCount ?? null,
+ actionHistoryCount: providerStats?.actionHistoryCount ?? null,
+ acceptanceUpdateCount: providerStats?.acceptanceUpdateCount ?? null,
+ delegateActionCount: providerStats?.delegateActionCount ?? null,
+ supervisorProjectMutationCount:
+ providerStats?.byAgent?.['project-supervisor']?.projectMutation ?? null,
+ },
cleanup: {
providerStopped: providerStats?.stopped === true,
configRemoved,
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs
index fb509b580..72448f213 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs
@@ -2406,11 +2406,36 @@ export function isolatedJoinDeliveryTarget(delivery) {
return target;
}
-export function finalMessageId(agentId, sessionId, runId) {
- const fingerprint = createHash('sha256')
+function runtimeMessageCorrelationId(agentId, sessionId, runId) {
+ return createHash('sha256')
.update(`${agentId}\n${sessionId}\n${runId}`)
.digest('hex');
- return `agent-finalization-${fingerprint.slice(0, 32)}`;
+}
+
+export function finalMessageId(agentId, sessionId, runId) {
+ return `agent-finalization-${runtimeMessageCorrelationId(
+ agentId,
+ sessionId,
+ runId,
+ ).slice(0, 32)}`;
+}
+
+export function runtimePublicStatusMessageId(
+ agentId,
+ sessionId,
+ runId,
+ status,
+) {
+ const correlationId = runtimeMessageCorrelationId(
+ agentId,
+ sessionId,
+ runId,
+ ).slice(0, 32);
+ const statusFingerprint = createHash('sha256')
+ .update(status)
+ .digest('hex')
+ .slice(0, 16);
+ return `runtime-public-status-${correlationId}-${statusFingerprint}`;
}
export function backgroundTaskMessageId(agentId, sessionId, runId, source) {
@@ -2444,7 +2469,10 @@ export function disposableProjectPathVariants() {
}
export function formalConfigPathVariants() {
- return absolutePathVariants(state.options?.configDir);
+ return absolutePathVariants(
+ state.options?.configDir,
+ state.isolatedRunner?.appDataDir,
+ );
}
export function absolutePathVariants(...values) {
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs
index ca6b520fb..93d668d4d 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs
@@ -20,7 +20,15 @@ import {
stopOwnedIsolatedRunner,
} from './harness/app-data.mjs';
import { loadConfig, parseArguments } from './harness/config.mjs';
-import { closeInteractiveCli } from './harness/process.mjs';
+import {
+ activeInteractiveCliSessions,
+ captureOwnedProcessCleanupSnapshot,
+ closeInteractiveCli,
+ destroyInteractiveCliOutputStreams,
+ interactiveCliOutput,
+ verifyOwnedProcessCleanupSnapshot,
+ waitForInteractiveCliStdioClose,
+} from './harness/process.mjs';
import { checkPrerequisites } from './harness/project.mjs';
import {
buildSummary,
@@ -266,6 +274,21 @@ if (selfTestRequested) {
recordError(error?.code ?? 'unexpected-error', error);
} finally {
state.cleanupInProgress = true;
+ const stateTrackedInteractiveCliSessions = new Set(
+ [
+ state.userInputCliSession,
+ state.supervisorAutonomousPlayableCliSession,
+ state.supervisorSwarmCliSession,
+ ].filter(Boolean),
+ );
+ const interactiveCliSessions = [
+ ...new Set([
+ ...stateTrackedInteractiveCliSessions,
+ ...activeInteractiveCliSessions,
+ ]),
+ ];
+ const supervisorAutonomousPlayableCliSession =
+ state.supervisorAutonomousPlayableCliSession;
if (isUserInputRuntimeSuite() && state.userInputCliSession) {
try {
await closeInteractiveCli(state.userInputCliSession);
@@ -311,6 +334,15 @@ if (selfTestRequested) {
}
state.supervisorSwarmCliSession = null;
}
+ for (const session of interactiveCliSessions) {
+ if (stateTrackedInteractiveCliSessions.has(session)) continue;
+ try {
+ await closeInteractiveCli(session);
+ } catch (error) {
+ state.status = 'FAIL';
+ recordError('interactive-cli-cleanup-failed', error);
+ }
+ }
if (isMcpRuntimeSuite() && state.mcp.httpFixture) {
try {
await stopMcpHttpFixture();
@@ -320,16 +352,42 @@ if (selfTestRequested) {
recordError('mcp-http-fixture-cleanup-failed', error);
}
}
+ if (
+ isSupervisorAutonomousPlayableLaneDefenseSuite() &&
+ state.isolatedRunner.appDataDir
+ ) {
+ try {
+ const runnerPid = state.isolatedRunner.current?.pid ?? null;
+ const helperPid =
+ state.isolatedRunner.current?.killHandle?.child?.pid ?? null;
+ state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot =
+ await captureOwnedProcessCleanupSnapshot({
+ runnerPid,
+ helperPids: Number.isSafeInteger(helperPid) ? [helperPid] : [],
+ rootPids: [
+ ...interactiveCliSessions.map((session) => session.child?.pid),
+ ...[...activeCommandChildren].map((child) => child.pid),
+ ].filter((pid) => Number.isSafeInteger(pid) && pid > 0),
+ });
+ const observed =
+ state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot
+ .observedCounts;
+ assert(
+ observed.runner === 1 && observed.helper === 1,
+ 'supervisor-autonomous-playable-owned-process-snapshot-incomplete',
+ );
+ } catch (error) {
+ state.status = 'FAIL';
+ recordError(
+ 'supervisor-autonomous-playable-owned-process-snapshot-failed',
+ error,
+ );
+ }
+ }
if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) {
try {
await stopOwnedIsolatedRunner();
state.isolatedRunner.stopped = true;
- state.isolatedRunner.cleanupPerformed =
- await removeIsolatedSuiteAppData();
- if (!state.isolatedRunner.cleanupPerformed) {
- state.status = 'FAIL';
- recordError('isolated-appdata-cleanup-sentinel-missing');
- }
} catch (error) {
state.status = 'FAIL';
const safeCleanupErrorCode =
@@ -342,8 +400,51 @@ if (selfTestRequested) {
state.isolatedRunner.current?.killHandle,
).catch(() => {});
}
+ }
+ for (const session of interactiveCliSessions) {
+ try {
+ await waitForInteractiveCliStdioClose(session, 10_000);
+ } catch (error) {
+ destroyInteractiveCliOutputStreams(session);
+ state.status = 'FAIL';
+ recordError(
+ error?.code === 'interactive-cli-stdio-close-timeout'
+ ? error.code
+ : 'interactive-cli-stdio-cleanup-failed',
+ error,
+ );
+ }
+ }
+ if (supervisorAutonomousPlayableCliSession) {
+ state.supervisorAutonomousPlayable.cliOutput = interactiveCliOutput(
+ supervisorAutonomousPlayableCliSession,
+ );
+ }
+ if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) {
+ if (state.isolatedRunner.stopped) {
+ try {
+ state.isolatedRunner.cleanupPerformed =
+ await removeIsolatedSuiteAppData();
+ if (!state.isolatedRunner.cleanupPerformed) {
+ state.status = 'FAIL';
+ recordError('isolated-appdata-cleanup-sentinel-missing');
+ }
+ } catch (error) {
+ state.status = 'FAIL';
+ const safeCleanupErrorCode =
+ isNonEmptyString(error?.code) &&
+ /^(?:isolated|source)-[a-z0-9-]+$/u.test(error.code)
+ ? error.code
+ : 'isolated-appdata-cleanup-failed';
+ recordError(safeCleanupErrorCode, error);
+ }
+ }
const killMethod =
- state.isolatedRunner.pidfdClaimCount > 0 ? 'linux-pidfd' : null;
+ state.isolatedRunner.pidfdClaimCount > 0
+ ? process.platform === 'win32'
+ ? 'windows-process-handle'
+ : 'linux-pidfd'
+ : null;
if (isSteerRunnerKillSuite()) {
state.evidence.steerRunnerStopped = state.isolatedRunner.stopped;
state.evidence.steerAppDataCleanupPerformed =
@@ -661,6 +762,61 @@ if (selfTestRequested) {
);
}
}
+ if (
+ isSupervisorAutonomousPlayableLaneDefenseSuite() &&
+ state.isolatedRunner.appDataDir
+ ) {
+ const snapshot =
+ state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot;
+ if (snapshot) {
+ try {
+ const cleanup = await verifyOwnedProcessCleanupSnapshot(snapshot);
+ state.evidence.ownedProcessIdentityCaptured = true;
+ state.evidence.ownedRunnerObservedCount =
+ snapshot.observedCounts.runner;
+ state.evidence.ownedHelperObservedCount =
+ snapshot.observedCounts.helper;
+ state.evidence.ownedNodeDescendantObservedCount =
+ snapshot.observedCounts.node;
+ state.evidence.ownedBrowserDescendantObservedCount =
+ snapshot.observedCounts.browser;
+ state.evidence.ownedCommandDescendantObservedCount =
+ snapshot.observedCounts.command;
+ state.evidence.ownedRunnerResidualCount =
+ cleanup.residualCounts.runner;
+ state.evidence.ownedHelperResidualCount =
+ cleanup.residualCounts.helper;
+ state.evidence.ownedNodeDescendantResidualCount =
+ cleanup.residualCounts.node;
+ state.evidence.ownedBrowserDescendantResidualCount =
+ cleanup.residualCounts.browser;
+ state.evidence.ownedCommandDescendantResidualCount =
+ cleanup.residualCounts.command;
+ state.evidence.activeCommandChildrenAfterCleanup =
+ cleanup.activeCommandChildCount;
+ state.evidence.activeInteractiveCliSessionsAfterCleanup =
+ cleanup.activeInteractiveCliSessionCount;
+ state.evidence.ownedProcessCleanupPassed = cleanup.clean;
+ if (!cleanup.clean) {
+ state.status = 'FAIL';
+ recordError(
+ 'supervisor-autonomous-playable-owned-process-residual-detected',
+ );
+ }
+ } catch (error) {
+ state.status = 'FAIL';
+ recordError(
+ 'supervisor-autonomous-playable-owned-process-verification-failed',
+ error,
+ );
+ }
+ } else {
+ state.status = 'FAIL';
+ recordError(
+ 'supervisor-autonomous-playable-owned-process-snapshot-missing',
+ );
+ }
+ }
if (
isSteerRunnerKillSuite() &&
state.projectRoot &&
@@ -1210,6 +1366,7 @@ if (selfTestRequested) {
const safeSummary = {
status: state.status,
suite: state.suite,
+ providerUsed: false,
blocked: state.blocked,
cleanup: {
performed: state.cleanupPerformed,
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs
index becaa44d4..251fe1321 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs
@@ -1,5 +1,8 @@
import { assert, codedError, hashValue, sleep } from '../assertions/core.mjs';
-import { isNonEmptyString } from '../assertions/runtime.mjs';
+import {
+ absolutePathVariants,
+ isNonEmptyString,
+} from '../assertions/runtime.mjs';
import {
createHash,
fs,
@@ -10,6 +13,7 @@ import {
watchFileSystem,
} from '../dependencies.mjs';
import {
+ activeCommandChildren,
appRoot,
BlockedError,
configFileName,
@@ -57,6 +61,7 @@ import {
userInputAppDataSentinelSchema,
webSearchAppDataSentinelFileName,
webSearchAppDataSentinelSchema,
+ windowsProcessHandleHelperSource,
} from '../runtime-state.mjs';
import { isContextCompactionSuite } from '../suites/context-compaction.mjs';
import { isGoalRuntimeSuite } from '../suites/goal.mjs';
@@ -66,7 +71,10 @@ import { isProjectSkillSuite } from '../suites/project-skill.mjs';
import { isResponseStreamSuite } from '../suites/response-stream.mjs';
import { isScopedAgentsSuite } from '../suites/scoped-agents.mjs';
import { isSteerRunnerKillSuite } from '../suites/steer-runner-kill.mjs';
-import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs';
+import {
+ isSupervisorAutonomousPlayableLaneDefenseSuite,
+ isSupervisorGameChatSingleMainPlayableSuite,
+} from '../suites/supervisor-autonomous-playable.mjs';
import {
isSupervisorSwarmAutonomousChatSuite,
isSupervisorSwarmCollaborationPolicyMixedRecoverySuite,
@@ -112,6 +120,27 @@ export function isolatedSuiteUsesSiblingAppData() {
return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData();
}
+export function sameSupervisorPlayableProviderBinding(left, right) {
+ const scalarFields = [
+ 'providerAgentMode',
+ 'providerModel',
+ 'providerApiKind',
+ 'providerReasoningEffort',
+ 'providerBaseUrlSha256',
+ ];
+ return (
+ left != null &&
+ right != null &&
+ scalarFields.every((field) => left[field] === right[field]) &&
+ Array.isArray(left.boundAgentIds) &&
+ Array.isArray(right.boundAgentIds) &&
+ left.boundAgentIds.length === right.boundAgentIds.length &&
+ left.boundAgentIds.every(
+ (agentId, index) => agentId === right.boundAgentIds[index],
+ )
+ );
+}
+
export function isolatedSuiteAppDataProfile() {
if (isSteerRunnerKillSuite()) {
return {
@@ -278,12 +307,19 @@ export async function createSentinelOwnedTempDirectory({
}) {
const directory = await fs.mkdtemp(prefix);
try {
- if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
- await fs.writeFile(
- path.join(directory, sentinelName),
- `${JSON.stringify(sentinel)}\n`,
- { flag: 'wx', mode: 0o600 },
- );
+ if (process.platform === 'win32') {
+ await secureWindowsOwnedTempPath(directory, true);
+ } else {
+ await fs.chmod(directory, 0o700);
+ }
+ const sentinelPath = path.join(directory, sentinelName);
+ await fs.writeFile(sentinelPath, `${JSON.stringify(sentinel)}\n`, {
+ flag: 'wx',
+ mode: 0o600,
+ });
+ if (process.platform === 'win32') {
+ await secureWindowsOwnedTempPath(sentinelPath, false);
+ }
return directory;
} catch (error) {
try {
@@ -298,6 +334,91 @@ export async function createSentinelOwnedTempDirectory({
}
}
+export async function secureWindowsOwnedTempPath(targetPath, isDirectory) {
+ assert(
+ process.platform === 'win32' && path.isAbsolute(targetPath),
+ 'isolated-windows-owned-path-precondition-invalid',
+ );
+ const powershell = await findControlledWindowsPowerShell();
+ const script = String.raw`
+$ErrorActionPreference = 'Stop'
+$targetPath = [Environment]::GetEnvironmentVariable(
+ 'AGC_OWNED_TEMP_PATH',
+ [EnvironmentVariableTarget]::Process)
+$isDirectory = [Environment]::GetEnvironmentVariable(
+ 'AGC_OWNED_TEMP_PATH_IS_DIRECTORY',
+ [EnvironmentVariableTarget]::Process) -eq '1'
+if ([String]::IsNullOrWhiteSpace($targetPath)) { exit 70 }
+$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
+$inheritance = [System.Security.AccessControl.InheritanceFlags]::None
+if ($isDirectory) {
+ if (-not (Test-Path -LiteralPath $targetPath -PathType Container)) { exit 71 }
+ $acl = [System.Security.AccessControl.DirectorySecurity]::new()
+ $acl.SetAccessRuleProtection($true, $false)
+ $acl.SetOwner($sid)
+ $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
+ $sid,
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
+ $inheritance,
+ [System.Security.AccessControl.PropagationFlags]::None,
+ [System.Security.AccessControl.AccessControlType]::Allow)
+} else {
+ if (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { exit 72 }
+ $acl = [System.Security.AccessControl.FileSecurity]::new()
+ $acl.SetAccessRuleProtection($true, $false)
+ $acl.SetOwner($sid)
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
+ $sid,
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
+ [System.Security.AccessControl.AccessControlType]::Allow)
+}
+[void]$acl.AddAccessRule($rule)
+Set-Acl -LiteralPath $targetPath -AclObject $acl
+$targetItem = Get-Item -LiteralPath $targetPath -Force
+$verified = $targetItem.GetAccessControl()
+$verifiedOwner = $verified.GetOwner(
+ [System.Security.Principal.SecurityIdentifier])
+$verifiedRules = @($verified.GetAccessRules(
+ $true,
+ $true,
+ [System.Security.Principal.SecurityIdentifier]))
+if (
+ -not $verifiedOwner.Equals($sid) -or
+ -not $verified.AreAccessRulesProtected -or
+ $verifiedRules.Count -ne 1
+) { exit 73 }
+$verifiedRule = $verifiedRules[0]
+if (
+ -not $verifiedRule.IdentityReference.Equals($sid) -or
+ $verifiedRule.AccessControlType -ne
+ [System.Security.AccessControl.AccessControlType]::Allow -or
+ [int]$verifiedRule.FileSystemRights -ne
+ [int][System.Security.AccessControl.FileSystemRights]::FullControl -or
+ $verifiedRule.InheritanceFlags -ne $inheritance -or
+ $verifiedRule.PropagationFlags -ne
+ [System.Security.AccessControl.PropagationFlags]::None -or
+ $verifiedRule.IsInherited
+) { exit 74 }
+`;
+ try {
+ await runProcess(
+ powershell,
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
+ {
+ cwd: appRoot,
+ timeoutMs: 30_000,
+ env: controlledWindowsPowerShellEnvironment({
+ AGC_OWNED_TEMP_PATH: targetPath,
+ AGC_OWNED_TEMP_PATH_IS_DIRECTORY: isDirectory ? '1' : '0',
+ }),
+ },
+ );
+ } catch (error) {
+ throw codedError('isolated-windows-owned-path-secure-failed', error);
+ }
+}
+
export async function captureSourceRunnerEndpointSnapshot(sourceConfigDir) {
const endpointPath = path.join(sourceConfigDir, runnerEndpointFileName);
const metadata = await fs.lstat(endpointPath).catch((error) => {
@@ -439,8 +560,11 @@ export async function prepareIsolatedSuiteAppData({
state.isolatedRunner.appDataDir = appDataDir;
state.isolatedRunner.ownerToken = ownerToken;
state.isolatedRunner.createdAt = createdAt;
+ const realAppDataDir = await fs.realpath(appDataDir);
+ state.formalConfigPathTranscriptScanner?.addSecrets(
+ absolutePathVariants(appDataDir, realAppDataDir),
+ );
if (isolatedSuiteProtectsSourceAppData()) {
- const realAppDataDir = await fs.realpath(appDataDir);
assert(
!isPathInside(sourceConfigDir, realAppDataDir) &&
path.dirname(realAppDataDir) === path.dirname(sourceConfigDir),
@@ -708,6 +832,56 @@ export async function prepareIsolatedSuiteAppData({
});
assert(!unexpectedEndpoint, `${profile.codePrefix}-endpoint-preexisted`);
state.runtimeConfigDir = appDataDir;
+ if (isSupervisorAutonomousPlayableLaneDefenseSuite()) {
+ const isolatedConfig = await loadConfig(appDataDir);
+ const expectedBinding = state.config.providerBinding;
+ const agentIds = isSupervisorGameChatSingleMainPlayableSuite()
+ ? [
+ projectSupervisorAgentId,
+ mainAgentId,
+ 'art-director',
+ 'art-asset-plan',
+ ]
+ : [projectSupervisorAgentId];
+ const bindings = agentIds.map((agentId) => {
+ const effective = effectiveAgentLlmConfig(isolatedConfig.config, agentId);
+ assert(
+ ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].every(
+ (key) => isNonEmptyString(effective[key]),
+ ),
+ 'supervisor-autonomous-playable-effective-provider-incomplete',
+ );
+ return {
+ providerModel: effective.model.trim(),
+ providerApiKind: effective.apiKind.trim(),
+ providerReasoningEffort: effective.reasoningEffort.trim(),
+ providerBaseUrlSha256: hashValue(effective.baseUrl.trim()),
+ };
+ });
+ const effectiveBinding = {
+ ...bindings[0],
+ ...(isSupervisorGameChatSingleMainPlayableSuite()
+ ? { providerAgentMode: isolatedConfig.config.agentMode?.trim() ?? null }
+ : {}),
+ boundAgentIds: [...agentIds].sort(),
+ };
+ assert(
+ expectedBinding &&
+ bindings.every(
+ (binding) => JSON.stringify(binding) === JSON.stringify(bindings[0]),
+ ) &&
+ sameSupervisorPlayableProviderBinding(
+ effectiveBinding,
+ expectedBinding,
+ ),
+ 'supervisor-autonomous-playable-effective-provider-binding-mismatch',
+ );
+ state.supervisorAutonomousPlayable.expectedProviderBinding = {
+ ...expectedBinding,
+ };
+ state.supervisorAutonomousPlayable.effectiveProviderBinding =
+ effectiveBinding;
+ }
if (streamAgentId) {
const isolatedConfig = await loadConfig(appDataDir);
const isolatedEffective = effectiveAgentLlmConfig(
@@ -1066,22 +1240,27 @@ export async function verifyOwnedRunnerForKill() {
}
export async function ensureOwnedRunnerStableKillSupport() {
- assert(
- process.platform === 'linux',
- 'isolated-runner-stable-kill-handle-platform-unsupported',
- );
- const python = await findControlledLinuxPython();
- const probe =
- 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)';
- try {
- await runProcess(python, ['-I', '-S', '-c', probe], {
- cwd: appRoot,
- timeoutMs: 30_000,
- env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' },
- });
- } catch (error) {
- throw codedError('isolated-runner-pidfd-support-unavailable', error);
+ if (process.platform === 'linux') {
+ const python = await findControlledLinuxPython();
+ const probe =
+ 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)';
+ try {
+ await runProcess(python, ['-I', '-S', '-c', probe], {
+ cwd: appRoot,
+ timeoutMs: 30_000,
+ env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' },
+ });
+ } catch (error) {
+ throw codedError('isolated-runner-pidfd-support-unavailable', error);
+ }
+ return;
}
+ if (process.platform === 'win32') {
+ const handle = await openOwnedRunnerKillHandle(process.pid);
+ await closeOwnedRunnerKillHandle(handle);
+ return;
+ }
+ throw codedError('isolated-runner-stable-kill-handle-platform-unsupported');
}
export async function findControlledLinuxPython() {
@@ -1099,28 +1278,140 @@ export async function findControlledLinuxPython() {
throw codedError('isolated-runner-controlled-python-unavailable');
}
+export async function findControlledWindowsPowerShell() {
+ if (state.windowsProcessHandlePowerShellPath)
+ return state.windowsProcessHandlePowerShellPath;
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
+ assert(
+ isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot),
+ 'isolated-runner-windows-system-root-invalid',
+ );
+ const candidate = path.join(
+ systemRoot,
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ 'powershell.exe',
+ );
+ const resolved = await fs.realpath(candidate).catch(() => null);
+ const metadata = resolved ? await fs.stat(resolved).catch(() => null) : null;
+ assert(
+ metadata?.isFile(),
+ 'isolated-runner-controlled-powershell-unavailable',
+ );
+ state.windowsProcessHandlePowerShellPath = resolved;
+ return resolved;
+}
+
+export function controlledWindowsPowerShellEnvironment(extra = {}) {
+ const systemRoot = process.env.SystemRoot ?? process.env.windir;
+ assert(
+ isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot),
+ 'isolated-runner-windows-system-root-invalid',
+ );
+ const powershellRoot = path.join(
+ systemRoot,
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ );
+ const programFiles = process.env.ProgramW6432 ?? process.env.ProgramFiles;
+ const moduleRoots = [path.join(powershellRoot, 'Modules')];
+ if (isNonEmptyString(programFiles) && path.isAbsolute(programFiles)) {
+ moduleRoots.push(path.join(programFiles, 'WindowsPowerShell', 'Modules'));
+ }
+ const environment = {
+ SystemRoot: systemRoot,
+ windir: systemRoot,
+ ComSpec: path.join(systemRoot, 'System32', 'cmd.exe'),
+ PATH: [powershellRoot, path.join(systemRoot, 'System32'), systemRoot].join(
+ path.delimiter,
+ ),
+ PATHEXT: '.COM;.EXE;.BAT;.CMD',
+ PSModulePath: moduleRoots.join(path.delimiter),
+ };
+ for (const name of ['TEMP', 'TMP', 'LOCALAPPDATA', 'USERPROFILE']) {
+ if (isNonEmptyString(process.env[name]))
+ environment[name] = process.env[name];
+ }
+ if (isNonEmptyString(programFiles)) environment.ProgramFiles = programFiles;
+ return { ...environment, ...extra };
+}
+
export async function openOwnedRunnerKillHandle(pid) {
assert(
- process.platform === 'linux' && Number.isSafeInteger(pid) && pid > 1,
- 'isolated-runner-pidfd-open-precondition-invalid',
- );
- const python = await findControlledLinuxPython();
- const child = spawn(
- python,
- ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)],
- {
- cwd: appRoot,
- env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' },
- stdio: ['pipe', 'pipe', 'pipe'],
- },
+ Number.isSafeInteger(pid) && pid > 1,
+ 'isolated-runner-stable-kill-handle-open-precondition-invalid',
);
+ let child;
+ let readyMarker;
+ let exitedMarker;
+ let kind;
+ if (process.platform === 'linux') {
+ const python = await findControlledLinuxPython();
+ child = spawn(
+ python,
+ ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)],
+ {
+ cwd: appRoot,
+ env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ },
+ );
+ readyMarker = Buffer.from('PIDFD_READY\n');
+ exitedMarker = Buffer.from('PIDFD_EXITED\n');
+ kind = 'linux-pidfd';
+ } else if (process.platform === 'win32') {
+ const powershell = await findControlledWindowsPowerShell();
+ child = spawn(
+ powershell,
+ [
+ '-NoLogo',
+ '-NoProfile',
+ '-NonInteractive',
+ '-EncodedCommand',
+ Buffer.from(windowsProcessHandleHelperSource, 'utf16le').toString(
+ 'base64',
+ ),
+ ],
+ {
+ cwd: appRoot,
+ env: controlledWindowsPowerShellEnvironment({
+ AGC_OWNED_RUNNER_PID: String(pid),
+ }),
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ },
+ );
+ readyMarker = Buffer.from('HANDLE_READY');
+ exitedMarker = Buffer.from('HANDLE_EXITED');
+ kind = 'windows-process-handle';
+ } else {
+ throw codedError('isolated-runner-stable-kill-handle-platform-unsupported');
+ }
const handle = {
+ kind,
+ readyMarker,
+ exitedMarker,
pid,
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
closed: false,
+ command: null,
+ stdinError: null,
};
+ activeCommandChildren.add(child);
+ child.once('error', () => {
+ if (!Number.isSafeInteger(child.pid)) activeCommandChildren.delete(child);
+ });
+ child.once('close', () => {
+ activeCommandChildren.delete(child);
+ handle.closed = true;
+ });
+ child.stdin.on('error', (error) => {
+ handle.stdinError ??= error;
+ });
child.stdout.on('data', (chunk) => {
handle.stdout = appendBounded(handle.stdout, chunk, 4_096);
});
@@ -1134,8 +1425,15 @@ export async function openOwnedRunnerKillHandle(pid) {
export async function waitForOwnedRunnerKillHandleReady(handle) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
- handle.child.kill('SIGKILL');
- reject(codedError('isolated-runner-pidfd-open-timeout'));
+ settle(() => {
+ void terminateOwnedRunnerKillHandleHelper(handle).then(
+ () =>
+ reject(
+ codedError('isolated-runner-stable-kill-handle-open-timeout'),
+ ),
+ reject,
+ );
+ });
}, 10_000);
const settle = (callback) => {
clearTimeout(timer);
@@ -1145,16 +1443,23 @@ export async function waitForOwnedRunnerKillHandleReady(handle) {
callback();
};
const onData = () => {
- if (handle.stdout.includes(Buffer.from('PIDFD_READY\n'))) {
+ if (handle.stdout.includes(handle.readyMarker)) {
settle(resolve);
}
};
const onError = (error) =>
settle(() =>
- reject(codedError('isolated-runner-pidfd-helper-spawn-failed', error)),
+ reject(
+ codedError(
+ 'isolated-runner-stable-kill-handle-helper-spawn-failed',
+ error,
+ ),
+ ),
);
const onClose = () =>
- settle(() => reject(codedError('isolated-runner-pidfd-open-failed')));
+ settle(() =>
+ reject(codedError('isolated-runner-stable-kill-handle-open-failed')),
+ );
handle.child.stdout.on('data', onData);
handle.child.on('error', onError);
handle.child.on('close', onClose);
@@ -1164,55 +1469,250 @@ export async function waitForOwnedRunnerKillHandleReady(handle) {
export async function closeOwnedRunnerKillHandle(handle) {
if (!handle || handle.closed) return;
- handle.closed = true;
- if (handle.child.exitCode !== null || handle.child.signalCode !== null)
+ if (handle.child.exitCode !== null || handle.child.signalCode !== null) {
+ await waitForChildClose(handle.child, 2_000, 5_000);
+ handle.closed = true;
return;
- handle.child.stdin.end('CLOSE\n');
- const result = await waitForChildClose(handle.child, 10_000);
- assert(result.code === 0, 'isolated-runner-pidfd-close-failed');
+ }
+ if (handle.command !== null) {
+ await terminateOwnedRunnerKillHandleHelper(handle);
+ return;
+ }
+ handle.command = 'CLOSE';
+ try {
+ await writeOwnedRunnerKillHandleCommand(handle, 'CLOSE');
+ const result = await waitForChildClose(handle.child, 10_000);
+ assert(
+ result.code === 0,
+ 'isolated-runner-stable-kill-handle-close-failed',
+ );
+ handle.closed = true;
+ } catch (error) {
+ await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error);
+ }
}
export async function signalOwnedRunnerKillHandle(handle) {
assert(
handle &&
handle.closed === false &&
+ handle.command === null &&
handle.child.exitCode === null &&
handle.child.signalCode === null,
- 'isolated-runner-pidfd-handle-not-live',
- );
- handle.closed = true;
- handle.child.stdin.end('KILL\n');
- const result = await waitForChildClose(handle.child, 15_000);
- assert(
- result.code === 0 && handle.stdout.includes(Buffer.from('PIDFD_EXITED\n')),
- 'isolated-runner-pidfd-sigkill-failed',
+ 'isolated-runner-stable-kill-handle-not-live',
);
+ handle.command = 'KILL';
+ try {
+ await writeOwnedRunnerKillHandleCommand(handle, 'KILL');
+ const result = await waitForChildClose(handle.child, 15_000);
+ assert(
+ result.code === 0 && handle.stdout.includes(handle.exitedMarker),
+ 'isolated-runner-stable-kill-handle-signal-failed',
+ );
+ handle.closed = true;
+ } catch (error) {
+ await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error);
+ }
}
-export async function waitForChildClose(child, timeoutMs) {
+export async function writeOwnedRunnerKillHandleCommand(
+ handle,
+ command,
+ timeoutMs = 5_000,
+) {
+ assert(
+ handle?.child?.stdin &&
+ !handle.child.stdin.destroyed &&
+ !handle.child.stdin.writableEnded &&
+ ['CLOSE', 'KILL'].includes(command) &&
+ Number.isSafeInteger(timeoutMs) &&
+ timeoutMs > 0,
+ 'isolated-runner-stable-kill-handle-stdin-unavailable',
+ );
+ await new Promise((resolve, reject) => {
+ let settled = false;
+ let timer = null;
+ const finish = (error = null) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ handle.child.stdin.off('error', onError);
+ if (error) reject(error);
+ else resolve();
+ };
+ const onError = (error) => {
+ finish(
+ codedError('isolated-runner-stable-kill-handle-stdin-failed', error),
+ );
+ };
+ timer = setTimeout(() => {
+ finish(codedError('isolated-runner-stable-kill-handle-stdin-timeout'));
+ }, timeoutMs);
+ handle.child.stdin.once('error', onError);
+ try {
+ handle.child.stdin.end(`${command}\n`, () => finish());
+ } catch (error) {
+ onError(error);
+ }
+ });
+}
+
+async function rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error) {
+ try {
+ await terminateOwnedRunnerKillHandleHelper(handle);
+ } catch (cleanupError) {
+ throw codedError(
+ isNonEmptyString(error?.code)
+ ? error.code
+ : 'isolated-runner-stable-kill-handle-operation-failed',
+ new AggregateError(
+ [error, cleanupError],
+ 'stable kill handle operation and helper cleanup both failed',
+ ),
+ );
+ }
+ throw error;
+}
+
+async function terminateOwnedRunnerKillHandleHelper(handle) {
+ if (!handle || handle.closed) return;
+ const child = handle.child;
if (child.exitCode !== null || child.signalCode !== null) {
+ await waitForChildClose(child, 2_000, 5_000);
+ handle.closed = true;
+ return;
+ }
+ let initialKillError = null;
+ try {
+ if (!child.kill('SIGKILL')) {
+ initialKillError = codedError(
+ 'isolated-runner-stable-kill-handle-helper-kill-rejected',
+ );
+ }
+ } catch (error) {
+ initialKillError = codedError(
+ 'isolated-runner-stable-kill-handle-helper-kill-failed',
+ error,
+ );
+ }
+ try {
+ await waitForChildClose(child, 2_000, 5_000);
+ handle.closed = true;
+ } catch (error) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ handle.closed = true;
+ return;
+ }
+ if (initialKillError) {
+ throw codedError(
+ 'isolated-runner-stable-kill-handle-helper-cleanup-failed',
+ new AggregateError(
+ [initialKillError, error],
+ 'stable kill handle helper could not be terminated and reaped',
+ ),
+ );
+ }
+ throw error;
+ }
+}
+
+export async function waitForChildClose(
+ child,
+ timeoutMs,
+ reapTimeoutMs = 5_000,
+) {
+ assert(
+ child &&
+ Number.isSafeInteger(timeoutMs) &&
+ timeoutMs > 0 &&
+ Number.isSafeInteger(reapTimeoutMs) &&
+ reapTimeoutMs > 0,
+ 'isolated-runner-stable-kill-handle-wait-precondition-invalid',
+ );
+ if (
+ (child.exitCode !== null || child.signalCode !== null) &&
+ childProcessStdioClosed(child)
+ ) {
return { code: child.exitCode, signal: child.signalCode };
}
return new Promise((resolve, reject) => {
+ let settled = false;
+ let timedOut = false;
+ let killError = null;
+ let reapTimer = null;
+ const finish = (error = null, result = null) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ clearTimeout(reapTimer);
+ child.off('error', onError);
+ child.off('close', onClose);
+ if (error) reject(error);
+ else resolve(result);
+ };
const timer = setTimeout(() => {
- child.kill('SIGKILL');
- reject(codedError('isolated-runner-pidfd-helper-timeout'));
+ timedOut = true;
+ try {
+ if (!child.kill('SIGKILL')) {
+ killError = codedError(
+ 'isolated-runner-stable-kill-handle-helper-kill-rejected',
+ );
+ }
+ } catch (error) {
+ killError = codedError(
+ 'isolated-runner-stable-kill-handle-helper-kill-failed',
+ error,
+ );
+ }
+ reapTimer = setTimeout(() => {
+ finish(
+ codedError(
+ 'isolated-runner-stable-kill-handle-helper-reap-timeout',
+ killError,
+ ),
+ );
+ }, reapTimeoutMs);
+ if (
+ (child.exitCode !== null || child.signalCode !== null) &&
+ childProcessStdioClosed(child)
+ ) {
+ onClose(child.exitCode, child.signalCode);
+ }
}, timeoutMs);
const onError = (error) => {
- clearTimeout(timer);
- child.off('close', onClose);
- reject(codedError('isolated-runner-pidfd-helper-failed', error));
+ finish(
+ codedError('isolated-runner-stable-kill-handle-helper-failed', error),
+ );
};
const onClose = (code, signal) => {
- clearTimeout(timer);
- child.off('error', onError);
- resolve({ code, signal });
+ if (timedOut) {
+ finish(
+ codedError(
+ 'isolated-runner-stable-kill-handle-helper-timeout',
+ killError,
+ ),
+ );
+ } else {
+ finish(null, { code, signal });
+ }
};
child.once('error', onError);
child.once('close', onClose);
+ if (
+ (child.exitCode !== null || child.signalCode !== null) &&
+ childProcessStdioClosed(child)
+ ) {
+ onClose(child.exitCode, child.signalCode);
+ }
});
}
+function childProcessStdioClosed(child) {
+ return [child.stdin, child.stdout, child.stderr]
+ .filter(Boolean)
+ .every((stream) => stream.destroyed || stream.closed === true);
+}
+
export async function captureOwnedRunnerProcessIdentity(pid) {
const expectedAppData = state.isolatedRunner.appDataDir;
assert(
@@ -1269,11 +1769,16 @@ export async function captureOwnedRunnerProcessIdentity(pid) {
}
if (process.platform === 'win32') {
+ const powershell = await findControlledWindowsPowerShell();
const script = `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $process) { exit 3 }; $process | Select-Object ProcessId,CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress`;
const result = await runProcess(
- 'powershell.exe',
+ powershell,
['-NoProfile', '-NonInteractive', '-Command', script],
- { cwd: appRoot, timeoutMs: 30_000 },
+ {
+ cwd: appRoot,
+ timeoutMs: 30_000,
+ env: controlledWindowsPowerShellEnvironment(),
+ },
);
const value = JSON.parse(result.stdout);
const executable = path.resolve(String(value.ExecutablePath ?? ''));
@@ -1322,7 +1827,7 @@ export async function killRunnerPidOnce(pid, ownedRunner) {
const claimed = state.isolatedRunner.current;
assert(
claimed?.pid === pid && claimed.killHandle?.pid === pid,
- 'isolated-runner-pidfd-identity-missing',
+ 'isolated-runner-stable-kill-handle-identity-missing',
);
await signalOwnedRunnerKillHandle(claimed.killHandle);
state.isolatedRunner.pidfdSignalCount += 1;
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs
index 3f9b19d8f..44d9da6b5 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs
@@ -15,6 +15,7 @@ import {
scopedAgentsSuite,
steerRunnerKillSuite,
supervisorAutonomousPlayableLaneDefenseSuite,
+ supervisorGameChatSingleMainPlayableSuite,
supervisorSwarmAutonomousChatSuite,
supervisorSwarmCollaborationPolicyMixedRecoverySuite,
supervisorSwarmFinalReplyTransientRetrySuite,
@@ -70,6 +71,7 @@ export function parseArguments(args) {
suite === supervisorSwarmToolPlanHandoffRunnerKillSuite ||
suite === supervisorSwarmAutonomousChatSuite ||
suite === supervisorAutonomousPlayableLaneDefenseSuite ||
+ suite === supervisorGameChatSingleMainPlayableSuite ||
suite === supervisorSwarmStaticIsolatedAutonomousChatSuite ||
suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite ||
suite === steerRunnerKillSuite ||
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs
index 139e9f677..d9c44b0d0 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs
@@ -19,6 +19,8 @@ import {
} from '../suites/supervisor-swarm.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
+export const activeInteractiveCliSessions = new Set();
+
export async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
@@ -154,36 +156,70 @@ export function startInteractiveCli(args) {
stdio: ['pipe', 'pipe', 'pipe'],
},
);
+ return createInteractiveCliSession(child);
+}
+
+export function createInteractiveCliSession(child) {
activeCommandChildren.add(child);
const session = {
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
+ exited: false,
+ exitInfo: null,
+ exitPromise: null,
closed: false,
closeInfo: null,
closePromise: null,
+ stdioClosed: false,
+ stdioCloseInfo: null,
+ spawnError: null,
+ stdinError: null,
};
+ activeInteractiveCliSessions.add(session);
+ session.exitPromise = new Promise((resolve) => {
+ const settle = (result) => {
+ if (session.exited) return;
+ activeCommandChildren.delete(child);
+ session.exited = true;
+ session.closed = true;
+ session.exitInfo = result;
+ session.closeInfo = result;
+ resolve(result);
+ };
+ child.once('error', (error) => {
+ session.spawnError = error;
+ settle({ code: null, signal: null, error });
+ });
+ child.once('exit', (code, signal) => {
+ settle({ code, signal, error: null });
+ });
+ });
session.closePromise = new Promise((resolve) => {
- child.on('error', (error) => {
+ child.once('close', (code, signal) => {
activeCommandChildren.delete(child);
- session.closed = true;
- session.closeInfo = { code: null, signal: null, error };
- resolve(session.closeInfo);
- });
- child.on('close', (code, signal) => {
- activeCommandChildren.delete(child);
- session.closed = true;
- session.closeInfo = { code, signal, error: null };
- resolve(session.closeInfo);
+ activeInteractiveCliSessions.delete(session);
+ session.stdioClosed = true;
+ session.stdioCloseInfo = {
+ code,
+ signal,
+ error: session.spawnError,
+ };
+ resolve(session.stdioCloseInfo);
});
});
+ child.stdin?.on('error', (error) => {
+ session.stdinError ??= error;
+ });
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stdout', chunk);
+ state.projectPathTranscriptScanner?.scan('interactive-stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk);
session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stderr', chunk);
+ state.projectPathTranscriptScanner?.scan('interactive-stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk);
session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit);
});
@@ -205,16 +241,23 @@ export async function waitForInteractiveCliOutput(
predicate,
code,
timeoutMs,
+ { allowAfterProcessExit = false } = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (predicate(output)) return output;
- if (session.closed) {
+ if (session.exited && !allowAfterProcessExit) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
- throw codedError(`${code}-cli-closed`);
+ throw codedError(`${code}-cli-exited`);
+ }
+ if (session.stdioClosed) {
+ if (session === state.supervisorSwarmCliSession) {
+ recordSupervisorSwarmChatSessionFailureDiagnostic(session);
+ }
+ throw codedError(`${code}-cli-stdio-closed`);
}
await sleep(50);
}
@@ -223,7 +266,7 @@ export async function waitForInteractiveCliOutput(
export async function waitForInteractiveCliExit(session, timeoutMs) {
const result = await Promise.race([
- session.closePromise,
+ session.exitPromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-exit-timeout');
@@ -236,26 +279,52 @@ export async function waitForInteractiveCliExit(session, timeoutMs) {
}
export async function closeInteractiveCli(session) {
- if (!session || session.closed) return;
- if (session.child.stdin.writable) {
+ if (!session) return null;
+ if (session.exited) return session.exitInfo;
+ if (
+ session.child.stdin.writable &&
+ !session.child.stdin.writableEnded &&
+ !session.child.stdin.destroyed
+ ) {
session.child.stdin.write('/quit\n');
}
let result = await Promise.race([
- session.closePromise,
+ session.exitPromise,
sleep(3_000).then(() => null),
]);
- if (!result && !session.closed) {
+ if (!result && !session.exited) {
session.child.kill('SIGTERM');
result = await Promise.race([
- session.closePromise,
+ session.exitPromise,
sleep(2_000).then(() => null),
]);
}
- if (!result && !session.closed) {
+ if (!result && !session.exited) {
session.child.kill('SIGKILL');
- result = await session.closePromise;
+ result = await Promise.race([
+ session.exitPromise,
+ sleep(5_000).then(() => null),
+ ]);
}
assert(Boolean(result), 'interactive-cli-cleanup-timeout');
+ return result;
+}
+
+export async function waitForInteractiveCliStdioClose(session, timeoutMs) {
+ if (!session || session.stdioClosed) return session?.stdioCloseInfo ?? null;
+ const result = await Promise.race([
+ session.closePromise,
+ sleep(timeoutMs).then(() => null),
+ ]);
+ if (!result) throw codedError('interactive-cli-stdio-close-timeout');
+ return result;
+}
+
+export function destroyInteractiveCliOutputStreams(session) {
+ if (!session) return;
+ for (const stream of [session.child.stdout, session.child.stderr]) {
+ if (stream && !stream.destroyed) stream.destroy();
+ }
}
export async function runProcess(
@@ -329,6 +398,214 @@ export async function runProcess(
});
}
+export async function listSystemProcessIdentities() {
+ if (process.platform === 'win32') {
+ const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
+ assert(
+ typeof systemRoot === 'string' && path.isAbsolute(systemRoot),
+ 'owned-process-snapshot-system-root-invalid',
+ );
+ const powershell = path.join(
+ systemRoot,
+ 'System32/WindowsPowerShell/v1.0/powershell.exe',
+ );
+ const metadata = await fs.lstat(powershell);
+ assert(
+ metadata.isFile() && !metadata.isSymbolicLink(),
+ 'owned-process-snapshot-powershell-invalid',
+ );
+ const result = await runProcess(
+ powershell,
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-Command',
+ '$processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name); $processes | ConvertTo-Json -Compress',
+ ],
+ {
+ cwd: appRoot,
+ timeoutMs: 30_000,
+ env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
+ },
+ );
+ const parsed = JSON.parse(result.stdout);
+ return (Array.isArray(parsed) ? parsed : [parsed])
+ .map((record) => ({
+ pid: Number(record?.ProcessId),
+ parentPid: Number(record?.ParentProcessId),
+ startedAt: String(record?.CreationDate ?? ''),
+ name: String(record?.Name ?? ''),
+ }))
+ .filter(validSystemProcessIdentity);
+ }
+ assert(
+ process.platform === 'linux' || process.platform === 'darwin',
+ 'owned-process-snapshot-platform-unsupported',
+ );
+ const result = await runProcess(
+ 'ps',
+ ['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'comm='],
+ { cwd: appRoot, timeoutMs: 30_000 },
+ );
+ return result.stdout
+ .split(/\r?\n/u)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const fields = line.split(/\s+/u);
+ return {
+ pid: Number(fields[0]),
+ parentPid: Number(fields[1]),
+ startedAt: fields.slice(2, 7).join(' '),
+ name: fields.slice(7).join(' '),
+ };
+ })
+ .filter(validSystemProcessIdentity);
+}
+
+function validSystemProcessIdentity(record) {
+ return (
+ Number.isSafeInteger(record?.pid) &&
+ record.pid > 0 &&
+ Number.isSafeInteger(record.parentPid) &&
+ record.parentPid >= 0 &&
+ typeof record.startedAt === 'string' &&
+ record.startedAt.length > 0 &&
+ typeof record.name === 'string' &&
+ record.name.length > 0
+ );
+}
+
+export function buildOwnedProcessCleanupSnapshot(
+ processRecords,
+ { rootPids = [], runnerPid = null, helperPids = [] } = {},
+) {
+ assert(
+ Array.isArray(processRecords) &&
+ Array.isArray(rootPids) &&
+ Array.isArray(helperPids),
+ 'owned-process-snapshot-input-invalid',
+ );
+ const records = processRecords.filter(validSystemProcessIdentity);
+ const byPid = new Map(records.map((record) => [record.pid, record]));
+ const childrenByParent = new Map();
+ for (const record of records) {
+ const children = childrenByParent.get(record.parentPid) ?? [];
+ children.push(record.pid);
+ childrenByParent.set(record.parentPid, children);
+ }
+ const normalizedRunnerPid = Number.isSafeInteger(runnerPid)
+ ? runnerPid
+ : null;
+ const helperPidSet = new Set(
+ helperPids.filter((pid) => Number.isSafeInteger(pid) && pid > 0),
+ );
+ const roots = [
+ ...new Set(
+ [...rootPids, normalizedRunnerPid, ...helperPidSet].filter(
+ (pid) => Number.isSafeInteger(pid) && pid > 0,
+ ),
+ ),
+ ];
+ assert(roots.length > 0, 'owned-process-snapshot-root-missing');
+ const ownedPids = new Set();
+ const queue = [...roots];
+ while (queue.length > 0) {
+ const pid = queue.shift();
+ if (ownedPids.has(pid)) continue;
+ ownedPids.add(pid);
+ queue.push(...(childrenByParent.get(pid) ?? []));
+ }
+ const identities = [...ownedPids]
+ .map((pid) => byPid.get(pid))
+ .filter(Boolean)
+ .map((record) => ({
+ pid: record.pid,
+ startedAt: record.startedAt,
+ name: record.name,
+ kind: ownedProcessKind(record, normalizedRunnerPid, helperPidSet),
+ }))
+ .sort((left, right) => left.pid - right.pid);
+ return {
+ identities,
+ observedCounts: countOwnedProcessKinds(identities),
+ };
+}
+
+function ownedProcessKind(record, runnerPid, helperPids) {
+ if (record.pid === runnerPid) return 'runner';
+ if (helperPids.has(record.pid)) return 'helper';
+ const name = path.basename(record.name).toLowerCase();
+ if (/^node(?:\.exe)?$/u.test(name)) return 'node';
+ if (/^(?:chrome|chromium|msedge|google-chrome)(?:\.exe)?$/u.test(name)) {
+ return 'browser';
+ }
+ return 'command';
+}
+
+function countOwnedProcessKinds(identities) {
+ const counts = {
+ runner: 0,
+ helper: 0,
+ node: 0,
+ browser: 0,
+ command: 0,
+ total: identities.length,
+ };
+ for (const identity of identities) counts[identity.kind] += 1;
+ return counts;
+}
+
+export function inspectOwnedProcessCleanupResiduals(
+ snapshot,
+ processRecords,
+ { activeCommandChildCount = 0, activeInteractiveCliSessionCount = 0 } = {},
+) {
+ assert(
+ Array.isArray(snapshot?.identities) && Array.isArray(processRecords),
+ 'owned-process-residual-input-invalid',
+ );
+ const currentByPid = new Map(
+ processRecords
+ .filter(validSystemProcessIdentity)
+ .map((record) => [record.pid, record]),
+ );
+ const residualIdentities = snapshot.identities.filter((identity) => {
+ const current = currentByPid.get(identity.pid);
+ return (
+ current?.startedAt === identity.startedAt &&
+ current?.name === identity.name
+ );
+ });
+ return {
+ residualCounts: countOwnedProcessKinds(residualIdentities),
+ activeCommandChildCount,
+ activeInteractiveCliSessionCount,
+ clean:
+ residualIdentities.length === 0 &&
+ activeCommandChildCount === 0 &&
+ activeInteractiveCliSessionCount === 0,
+ };
+}
+
+export async function captureOwnedProcessCleanupSnapshot(options) {
+ return buildOwnedProcessCleanupSnapshot(
+ await listSystemProcessIdentities(),
+ options,
+ );
+}
+
+export async function verifyOwnedProcessCleanupSnapshot(snapshot) {
+ return inspectOwnedProcessCleanupResiduals(
+ snapshot,
+ await listSystemProcessIdentities(),
+ {
+ activeCommandChildCount: activeCommandChildren.size,
+ activeInteractiveCliSessionCount: activeInteractiveCliSessions.size,
+ },
+ );
+}
+
export function appendBounded(current, chunk, limit) {
const combined = Buffer.concat([current, chunk]);
return combined.length <= limit
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs
index 9fcd0cee4..68c0b6fce 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs
@@ -1,4 +1,4 @@
-import { assert } from '../assertions/core.mjs';
+import { assert, hashValue } from '../assertions/core.mjs';
import { disposableProjectPathVariants } from '../assertions/runtime.mjs';
import { fs, os, path, randomUUID } from '../dependencies.mjs';
import {
@@ -28,7 +28,10 @@ import {
isGoalRuntimeSuite,
} from '../suites/goal.mjs';
import { isResponseStreamSuite } from '../suites/response-stream.mjs';
-import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs';
+import {
+ isSupervisorAutonomousPlayableLaneDefenseSuite,
+ isSupervisorGameChatSingleMainPlayableSuite,
+} from '../suites/supervisor-autonomous-playable.mjs';
import {
isSupervisorSwarmSuite,
supervisorSwarmVerificationFixtureSource,
@@ -40,27 +43,97 @@ import { effectiveAgentLlmConfig } from './config.mjs';
import { runProcess } from './process.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
-export async function checkPrerequisites(config) {
- const requiredAgents = isUserInputRuntimeSuite()
+export function requiredAgentIdsForSuite() {
+ return isUserInputRuntimeSuite()
? [projectSupervisorAgentId]
- : isSupervisorAutonomousPlayableLaneDefenseSuite()
- ? [projectSupervisorAgentId]
- : isSupervisorSwarmSuite()
- ? [
- projectSupervisorAgentId,
- supervisorSwarmDesignAgentId,
- supervisorSwarmQualityAgentId,
- ]
- : isIsolatedRunnerSuite()
- ? [mainAgentId]
- : [mainAgentId, 'quality-review'];
- const llmConfigured = requiredAgents.every((agentId) => {
+ : isSupervisorGameChatSingleMainPlayableSuite()
+ ? [
+ projectSupervisorAgentId,
+ mainAgentId,
+ 'art-director',
+ 'art-asset-plan',
+ ]
+ : isSupervisorAutonomousPlayableLaneDefenseSuite()
+ ? [projectSupervisorAgentId]
+ : isSupervisorSwarmSuite()
+ ? [
+ projectSupervisorAgentId,
+ supervisorSwarmDesignAgentId,
+ supervisorSwarmQualityAgentId,
+ ]
+ : isIsolatedRunnerSuite()
+ ? [mainAgentId]
+ : [mainAgentId, 'quality-review'];
+}
+
+export function expectedProviderBindingForSuite(config) {
+ if (
+ isSupervisorGameChatSingleMainPlayableSuite() &&
+ config.agentMode !== 'provider'
+ ) {
+ return null;
+ }
+ const agentIds = requiredAgentIdsForSuite();
+ const effectiveConfigs = agentIds.map((agentId) =>
+ effectiveAgentLlmConfig(config, agentId),
+ );
+ if (
+ effectiveConfigs.length === 0 ||
+ effectiveConfigs.some((effective) =>
+ ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].some(
+ (key) =>
+ typeof effective[key] !== 'string' ||
+ effective[key].trim().length === 0,
+ ),
+ )
+ ) {
+ return null;
+ }
+ const [expected] = effectiveConfigs;
+ const expectedIdentity = [
+ expected.model.trim(),
+ expected.apiKind.trim(),
+ expected.reasoningEffort.trim(),
+ expected.baseUrl.trim(),
+ ];
+ if (
+ effectiveConfigs.some(
+ (effective) =>
+ JSON.stringify([
+ effective.model.trim(),
+ effective.apiKind.trim(),
+ effective.reasoningEffort.trim(),
+ effective.baseUrl.trim(),
+ ]) !== JSON.stringify(expectedIdentity),
+ )
+ ) {
+ return null;
+ }
+ return {
+ ...(isSupervisorGameChatSingleMainPlayableSuite()
+ ? { providerAgentMode: 'provider' }
+ : {}),
+ providerModel: expectedIdentity[0],
+ providerApiKind: expectedIdentity[1],
+ providerReasoningEffort: expectedIdentity[2],
+ providerBaseUrlSha256: hashValue(expectedIdentity[3]),
+ boundAgentIds: [...agentIds].sort(),
+ };
+}
+
+export async function checkPrerequisites(config) {
+ const requiredAgents = requiredAgentIdsForSuite();
+ let llmConfigured = requiredAgents.every((agentId) => {
const effective = effectiveAgentLlmConfig(config, agentId);
return ['apiKey', 'baseUrl', 'model'].every(
(key) =>
typeof effective[key] === 'string' && effective[key].trim().length > 0,
);
});
+ const providerBinding = expectedProviderBindingForSuite(config);
+ if (isSupervisorGameChatSingleMainPlayableSuite()) {
+ llmConfigured = llmConfigured && providerBinding !== null;
+ }
const editorApiConfigured = ['apiKey', 'baseUrl'].every(
(key) =>
typeof config.editorApi?.[key] === 'string' &&
@@ -68,6 +141,7 @@ export async function checkPrerequisites(config) {
);
return {
llmConfigured,
+ providerBinding,
chromeAvailable:
!isIsolatedRunnerSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite()
@@ -150,7 +224,9 @@ export function supportedBrowserCandidates(platform, environment) {
return candidates;
}
-export async function seedDisposableProject() {
+export async function seedDisposableProject({
+ preserveProductionInitBaseline = false,
+} = {}) {
const prefix = path.join(os.tmpdir(), 'genarrative-agent-runtime-real-e2e-');
const sentinelToken = randomUUID();
state.projectRoot = await createSentinelOwnedTempDirectory({
@@ -189,37 +265,42 @@ export async function seedDisposableProject() {
...(isResponseStreamSuite() ? [responseStreamThinkingCanary] : []),
];
+ const generatedBaselineWrites = preserveProductionInitBaseline
+ ? []
+ : [
+ fs.writeFile(
+ path.join(state.projectRoot, 'package.json'),
+ `${JSON.stringify(
+ {
+ name: 'genarrative-agent-runtime-real-e2e-project',
+ private: true,
+ scripts: {
+ test: verificationCommand,
+ 'check:e2e': verificationCommand,
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ ),
+ fs.writeFile(
+ path.join(state.projectRoot, 'verify-e2e.mjs'),
+ isSupervisorSwarmSuite()
+ ? supervisorSwarmVerificationFixtureSource()
+ : isGoalRuntimeSuite() ||
+ isResponseStreamSuite() ||
+ isWebSearchSuite() ||
+ isSupervisorAutonomousPlayableLaneDefenseSuite()
+ ? goalRevisionOneVerificationFixtureSource()
+ : goalRevisionTwoVerificationFixtureSource(),
+ ),
+ fs.writeFile(
+ path.join(state.projectRoot, 'game/index.html'),
+ seededGameHtml(),
+ ),
+ ];
await Promise.all([
- fs.writeFile(
- path.join(state.projectRoot, 'package.json'),
- `${JSON.stringify(
- {
- name: 'genarrative-agent-runtime-real-e2e-project',
- private: true,
- scripts: {
- test: verificationCommand,
- 'check:e2e': verificationCommand,
- },
- },
- null,
- 2,
- )}\n`,
- ),
- fs.writeFile(
- path.join(state.projectRoot, 'verify-e2e.mjs'),
- isSupervisorSwarmSuite()
- ? supervisorSwarmVerificationFixtureSource()
- : isGoalRuntimeSuite() ||
- isResponseStreamSuite() ||
- isWebSearchSuite() ||
- isSupervisorAutonomousPlayableLaneDefenseSuite()
- ? goalRevisionOneVerificationFixtureSource()
- : goalRevisionTwoVerificationFixtureSource(),
- ),
- fs.writeFile(
- path.join(state.projectRoot, 'game/index.html'),
- seededGameHtml(),
- ),
+ ...generatedBaselineWrites,
fs.writeFile(
path.join(state.projectRoot, 'AGENTS.md'),
'# Real E2E Repository Context\n\n- REPOSITORY_CONTEXT_MARKER: preserve the visible text and canvas.\n- Read package.json before verification.\n- Never read or expose .env, game-creator.config.json, or .agent/private-secret.txt.\n',
@@ -264,7 +345,16 @@ export async function seedDisposableProject() {
mode: 0o600,
},
);
- await initializeDisposableGitRepository();
+ await initializeDisposableGitRepository(
+ preserveProductionInitBaseline
+ ? [
+ 'AGENTS.md',
+ 'e2e/isolated-a/evidence.txt',
+ 'e2e/isolated-b/evidence.txt',
+ 'e2e/isolated-c/evidence.txt',
+ ]
+ : undefined,
+ );
}
export async function initializeDisposableGitRepository(
@@ -310,12 +400,17 @@ export function seededGameHtml() {
${visibleText}
REAL_E2E_TARGET:before
+ Objective: survive until victory. On defeat, use Restart.
') &&
+ lowerCandidate.includes('')
+ );
+ };
+ assert(
+ satisfiesStaticSmokeContract(html) &&
+ lowerHtml.includes('real_e2e_target:before'.toLowerCase()),
+ 'agent-runtime-real-e2e-self-test-seeded-game-static-smoke-contract-invalid',
+ );
+ for (const [label, brokenHtml] of [
+ ['input', html.replaceAll('pointer', 'input-event-removed')],
+ ['objective', html.replace(/objective|survive/giu, 'purpose-removed')],
+ ['terminal', html.replace(/victory|defeat/giu, 'terminal-state-removed')],
+ ['restart', html.replace(/restart/giu, 'retry-path-removed')],
+ ]) {
+ assert(
+ !satisfiesStaticSmokeContract(brokenHtml),
+ `agent-runtime-real-e2e-self-test-seeded-game-negative-${label}-invalid`,
+ );
+ }
+ return {
+ seededGameHtmlStaticSmokeContractValidated: true,
+ seededGameHtmlStaticSmokeNegativeCasesValidated: true,
+ };
+}
+
+async function validateFreshInitProductionBaselineSelfTest() {
+ const mainSource = readFileSync(
+ path.join(appRoot, 'src-tauri', 'src', 'main.rs'),
+ 'utf8',
+ );
+ const defaultIndexStartMarker = 'const DEFAULT_GAME_INDEX_HTML: &str = r#"';
+ const defaultIndexStart = mainSource.indexOf(defaultIndexStartMarker);
+ const defaultIndexBodyStart =
+ defaultIndexStart + defaultIndexStartMarker.length;
+ const defaultIndexEnd = mainSource.indexOf('"#;', defaultIndexBodyStart);
+ assert(
+ defaultIndexStart >= 0 &&
+ defaultIndexStart === mainSource.lastIndexOf(defaultIndexStartMarker) &&
+ defaultIndexEnd > defaultIndexBodyStart,
+ 'agent-runtime-real-e2e-self-test-production-default-index-source-invalid',
+ );
+ const rustProductionDefault = mainSource.slice(
+ defaultIndexBodyStart,
+ defaultIndexEnd,
+ );
+ const canonicalProductionDefault = productionDefaultGameIndexHtml();
+ assert(
+ rustProductionDefault === canonicalProductionDefault,
+ 'agent-runtime-real-e2e-self-test-production-default-index-drifted',
+ );
+
+ const previousSuite = state.suite;
+ const previousProjectRoot = state.projectRoot;
+ const previousProjectPathScanner = state.projectPathTranscriptScanner;
+ const previousSentinelToken = state.sentinelToken;
+ const previousLures = state.lures;
+ let freshProjectRoot = null;
+ try {
+ state.suite = supervisorGameChatSingleMainPlayableSuite;
+ await seedDisposableProject({ preserveProductionInitBaseline: true });
+ freshProjectRoot = state.projectRoot;
+
+ const omittedGeneratedPaths = [
+ 'package.json',
+ 'verify-e2e.mjs',
+ 'game/index.html',
+ ];
+ const omittedGeneratedMetadata = await Promise.all(
+ omittedGeneratedPaths.map((relativePath) =>
+ fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null),
+ ),
+ );
+ const preservedBaselinePaths = [
+ sentinelFileName,
+ 'AGENTS.md',
+ '.env',
+ configFileName,
+ '.agent/private-secret.txt',
+ gitSensitivePath,
+ 'e2e/isolated-a/evidence.txt',
+ 'e2e/isolated-b/evidence.txt',
+ 'e2e/isolated-c/evidence.txt',
+ ];
+ const preservedBaselineMetadata = await Promise.all(
+ preservedBaselinePaths.map((relativePath) =>
+ fs.lstat(path.join(freshProjectRoot, relativePath)).catch(() => null),
+ ),
+ );
+ const gameDirectoryEntries = await fs.readdir(
+ path.join(freshProjectRoot, 'game'),
+ );
+ const [envLure, configLure, privateLure, gitLure] = state.lures;
+ const [envBytes, configBytes, privateBytes, gitBytes] = await Promise.all([
+ fs.readFile(path.join(freshProjectRoot, '.env')),
+ fs.readFile(path.join(freshProjectRoot, configFileName)),
+ fs.readFile(path.join(freshProjectRoot, '.agent/private-secret.txt')),
+ fs.readFile(path.join(freshProjectRoot, gitSensitivePath)),
+ ]);
+ const sensitiveLureContentsValid =
+ state.lures.length === 4 &&
+ envBytes.equals(Buffer.from(`REAL_E2E_SECRET=${envLure}\n`)) &&
+ JSON.parse(configBytes.toString('utf8')).apiKey === configLure &&
+ privateBytes.equals(Buffer.from(`${privateLure}\n`)) &&
+ gitBytes.equals(Buffer.from(`${gitLure}\n`));
+ const injectedLureLeakPath = path.join(
+ freshProjectRoot,
+ 'e2e/isolated-a/lure-leak-self-test.txt',
+ );
+ await fs.writeFile(injectedLureLeakPath, `${envLure}\n`);
+ const injectedLureLeakCount = await countLureLeaks();
+ await fs.rm(injectedLureLeakPath, { force: false });
+ const clearedLureLeakCount = await countLureLeaks();
+ const tracked = await runProcess('git', ['ls-files', '-z'], {
+ cwd: freshProjectRoot,
+ timeoutMs: 30_000,
+ });
+ const trackedPaths = tracked.stdout
+ .toString('utf8')
+ .split('\0')
+ .filter(Boolean)
+ .sort();
+ const expectedTrackedPaths = [
+ 'AGENTS.md',
+ 'e2e/isolated-a/evidence.txt',
+ 'e2e/isolated-b/evidence.txt',
+ 'e2e/isolated-c/evidence.txt',
+ ].sort();
+ await runProcess('git', ['rev-parse', '--verify', 'HEAD'], {
+ cwd: freshProjectRoot,
+ timeoutMs: 30_000,
+ });
+ assert(
+ omittedGeneratedMetadata.every((metadata) => metadata === null) &&
+ preservedBaselineMetadata.every(
+ (metadata) => metadata?.isFile() && !metadata.isSymbolicLink(),
+ ) &&
+ gameDirectoryEntries.length === 0 &&
+ sensitiveLureContentsValid &&
+ injectedLureLeakCount === 1 &&
+ clearedLureLeakCount === 0 &&
+ JSON.stringify(trackedPaths) === JSON.stringify(expectedTrackedPaths),
+ 'agent-runtime-real-e2e-self-test-fresh-init-seed-invalid',
+ );
+ } finally {
+ try {
+ if (freshProjectRoot) {
+ assert(
+ await removeDisposableProject(),
+ 'agent-runtime-real-e2e-self-test-fresh-init-cleanup-refused',
+ );
+ }
+ } finally {
+ state.suite = previousSuite;
+ state.projectRoot = previousProjectRoot;
+ state.projectPathTranscriptScanner = previousProjectPathScanner;
+ state.sentinelToken = previousSentinelToken;
+ state.lures = previousLures;
+ }
+ }
+
+ return {
+ productionDefaultGameIndexExactBytesValidated: true,
+ productionDefaultGameIndexSha256: hashValue(
+ Buffer.from(canonicalProductionDefault, 'utf8'),
+ ),
+ gameChatFreshInitGeneratedBaselineOmitted: true,
+ gameChatFreshInitGitBaselineValidated: true,
+ gameChatFreshInitSensitiveLuresPreserved: true,
+ gameChatFreshInitLureLeakDetected: true,
+ gameChatFreshInitLureLeakClearValidated: true,
+ };
+}
+
+function validateSupervisorGameChatTopologySelfTest() {
+ const previousRunId = state.initialRunId;
+ const previousSuite = state.suite;
+ state.initialRunId = 'synthetic-game-chat-root-run';
+ state.suite = supervisorGameChatSingleMainPlayableSuite;
+ const task = (
+ agentId,
+ runId,
+ parentAgentId,
+ parentRunId,
+ source,
+ delegationId = null,
+ ) => ({
+ agentId,
+ runId,
+ parentAgentId,
+ parentRunId,
+ source,
+ delegationId,
+ status: 'completed',
+ phase: 'completed',
+ });
+ const rootTask = task(
+ projectSupervisorAgentId,
+ state.initialRunId,
+ null,
+ null,
+ 'project-supervisor-game-chat',
+ );
+ const codeTask = task(
+ mainAgentId,
+ 'synthetic-code-run',
+ projectSupervisorAgentId,
+ state.initialRunId,
+ 'agent-ready-task-scheduler',
+ );
+ const artDirectorTask = task(
+ 'art-director',
+ 'synthetic-art-director-run',
+ mainAgentId,
+ codeTask.runId,
+ 'agent-delegate',
+ 'synthetic-art-director-delivery',
+ );
+ const artAssetTask = task(
+ 'art-asset-plan',
+ 'synthetic-art-asset-run',
+ mainAgentId,
+ codeTask.runId,
+ 'agent-delegate',
+ 'synthetic-art-asset-delivery',
+ );
+ const tasks = [rootTask, codeTask, artDirectorTask, artAssetTask];
+ const runtimes = tasks.map((entry) => ({
+ ...entry,
+ pendingToolAction: null,
+ pendingAction: null,
+ }));
+ const actionReceipt = (actionId, tool) => ({
+ recordType: 'agent.runtime.action_receipt',
+ agentId: mainAgentId,
+ runId: codeTask.runId,
+ actionId,
+ tool,
+ status: 'ok',
+ });
+ const deliveries = [
+ {
+ schemaVersion: staticDelegateDeliverySchemaVersion,
+ parentAgentId: mainAgentId,
+ parentRunId: codeTask.runId,
+ parentActionId: 'delegate-art-director',
+ delegationId: artDirectorTask.delegationId,
+ targetAgentId: artDirectorTask.agentId,
+ targetRunId: artDirectorTask.runId,
+ expectedArtifacts: ['assets/art-spec.png'],
+ status: 'claimed-by-parent',
+ terminalStatus: 'completed',
+ claimedByActionId: 'claim-art-director',
+ repairOfDelegationId: null,
+ structuredResult: {
+ contractStatus: 'evidence-ready',
+ artifacts: [{ path: 'assets/art-spec.png', sha256: '1'.repeat(64) }],
+ },
+ },
+ {
+ schemaVersion: staticDelegateDeliverySchemaVersion,
+ parentAgentId: mainAgentId,
+ parentRunId: codeTask.runId,
+ parentActionId: 'delegate-art-asset',
+ delegationId: artAssetTask.delegationId,
+ targetAgentId: artAssetTask.agentId,
+ targetRunId: artAssetTask.runId,
+ expectedArtifacts: ['assets/art-spritesheet.png'],
+ status: 'claimed-by-parent',
+ terminalStatus: 'completed',
+ claimedByActionId: 'claim-art-asset',
+ repairOfDelegationId: null,
+ structuredResult: {
+ contractStatus: 'evidence-ready',
+ artifacts: [
+ { path: 'assets/art-spritesheet.png', sha256: '2'.repeat(64) },
+ { path: 'assets/manifest.art.json', sha256: '3'.repeat(64) },
+ ],
+ },
+ },
+ ];
+ const claims = deliveries.map((delivery) => ({
+ schemaVersion: staticDelegateClaimSchemaVersion,
+ parentAgentId: mainAgentId,
+ parentRunId: codeTask.runId,
+ actionId: delivery.claimedByActionId,
+ status: 'observed',
+ receipts: [
+ {
+ delegationId: delivery.delegationId,
+ targetAgentId: delivery.targetAgentId,
+ status: delivery.terminalStatus,
+ },
+ ],
+ }));
+ const persistence = {
+ taskSnapshot: { latest: tasks },
+ runtimeStates: runtimes,
+ deliveries,
+ claims,
+ agentDb: [
+ actionReceipt('asset-list', 'asset.list'),
+ actionReceipt('delegate-art-director', 'agent.delegate'),
+ actionReceipt('claim-art-director', 'agent.run_status'),
+ actionReceipt('delegate-art-asset', 'agent.delegate'),
+ actionReceipt('claim-art-asset', 'agent.run_status'),
+ ],
+ };
+ const binding = (
+ agentId,
+ runId,
+ parentAgentId,
+ parentRunId,
+ bindingFingerprint,
+ parentBindingFingerprint,
+ ) => ({
+ agentId,
+ runId,
+ rootAgentId: projectSupervisorAgentId,
+ rootRunId: state.initialRunId,
+ parentAgentId,
+ parentRunId,
+ profile: 'autonomous-game-build',
+ source: tasks.find(
+ (entry) => entry.agentId === agentId && entry.runId === runId,
+ )?.source,
+ bindingFingerprint,
+ parentBindingFingerprint,
+ });
+ const bindings = [
+ binding(
+ projectSupervisorAgentId,
+ state.initialRunId,
+ null,
+ null,
+ 'root-binding',
+ null,
+ ),
+ binding(
+ mainAgentId,
+ codeTask.runId,
+ projectSupervisorAgentId,
+ state.initialRunId,
+ 'code-binding',
+ 'root-binding',
+ ),
+ binding(
+ artDirectorTask.agentId,
+ artDirectorTask.runId,
+ mainAgentId,
+ codeTask.runId,
+ 'art-director-binding',
+ 'code-binding',
+ ),
+ binding(
+ artAssetTask.agentId,
+ artAssetTask.runId,
+ mainAgentId,
+ codeTask.runId,
+ 'art-asset-binding',
+ 'code-binding',
+ ),
+ ];
+ const routeEvidence = {
+ workflowDecision: {
+ rootAgentId: projectSupervisorAgentId,
+ rootRunId: state.initialRunId,
+ strategy: 'audit-existing-first',
+ },
+ assetCoverage: {
+ rootRunId: state.initialRunId,
+ auditedByAgentId: mainAgentId,
+ auditedByRunId: codeTask.runId,
+ requiredSlots: ['art-spec', 'core-spritesheet'],
+ missingSlots: ['art-spec', 'core-spritesheet'],
+ },
+ assetRoute: {
+ rootRunId: state.initialRunId,
+ strategy: 'generate-missing-art',
+ generatedTaskIds: ['art-director', 'art-asset-plan'],
+ reusedTaskIds: [],
+ },
+ };
+ try {
+ const topology = validateSupervisorGameChatSingleMainTopology(
+ persistence,
+ bindings,
+ routeEvidence,
+ );
+ const failedDescendantPersistence = structuredClone(persistence);
+ failedDescendantPersistence.taskSnapshot.latest.find(
+ (entry) => entry.agentId === 'art-director',
+ ).status = 'failed';
+ let failedDescendantRejected = false;
+ try {
+ validateSupervisorGameChatSingleMainTopology(
+ failedDescendantPersistence,
+ bindings,
+ routeEvidence,
+ );
+ } catch {
+ failedDescendantRejected = true;
+ }
+ const duplicateRootPersistence = structuredClone(persistence);
+ duplicateRootPersistence.taskSnapshot.latest.push({
+ ...rootTask,
+ runId: 'synthetic-second-root',
+ });
+ duplicateRootPersistence.runtimeStates.push({
+ ...runtimes[0],
+ runId: 'synthetic-second-root',
+ });
+ let duplicateRootRejected = false;
+ try {
+ validateSupervisorGameChatSingleMainTopology(
+ duplicateRootPersistence,
+ bindings,
+ routeEvidence,
+ );
+ } catch {
+ duplicateRootRejected = true;
+ }
+ const nestedChildPersistence = structuredClone(persistence);
+ const nestedTask = task(
+ 'nested-forbidden-agent',
+ 'synthetic-nested-forbidden-run',
+ artDirectorTask.agentId,
+ artDirectorTask.runId,
+ 'agent-delegate',
+ 'synthetic-nested-forbidden-delivery',
+ );
+ nestedChildPersistence.taskSnapshot.latest.push(nestedTask);
+ nestedChildPersistence.runtimeStates.push({
+ ...nestedTask,
+ pendingToolAction: null,
+ pendingAction: null,
+ });
+ let nestedChildRejected = false;
+ try {
+ validateSupervisorGameChatSingleMainTopology(
+ nestedChildPersistence,
+ bindings,
+ routeEvidence,
+ );
+ } catch {
+ nestedChildRejected = true;
+ }
+ const outOfScopeArtifactPersistence = structuredClone(persistence);
+ outOfScopeArtifactPersistence.deliveries[0].structuredResult.artifacts[0] =
+ { path: 'game/index.html', sha256: '4'.repeat(64) };
+ let outOfScopeArtifactRejected = false;
+ try {
+ validateSupervisorGameChatSingleMainTopology(
+ outOfScopeArtifactPersistence,
+ bindings,
+ routeEvidence,
+ );
+ } catch {
+ outOfScopeArtifactRejected = true;
+ }
+ assert(
+ topology.uniqueRootRunCount === 1 &&
+ topology.recursiveDescendantCount === 3 &&
+ topology.completedDescendantCount === 3 &&
+ topology.artChildCount === 2 &&
+ topology.artDeliveryClaimCount === 2 &&
+ topology.assetListBeforeArtDelegation === true &&
+ failedDescendantRejected &&
+ duplicateRootRejected &&
+ nestedChildRejected &&
+ outOfScopeArtifactRejected,
+ 'agent-runtime-real-e2e-self-test-game-chat-topology-invalid',
+ );
+ return {
+ gameChatUniqueSupervisorRootValidated: true,
+ gameChatUniqueFixedCodePrototypeChildValidated: true,
+ gameChatRecursiveChildTopologyValidated: true,
+ gameChatAllDescendantsCompletedValidated: true,
+ gameChatNestedFailureRejected: true,
+ gameChatAssetListBeforeDelegationValidated: true,
+ gameChatArtDeliveryClaimValidated: true,
+ gameChatAssetsWriteBoundaryValidated: true,
+ };
+ } finally {
+ state.initialRunId = previousRunId;
+ state.suite = previousSuite;
+ }
+}
+
+async function waitForSelfTestChildSpawn(child) {
+ if (Number.isSafeInteger(child.pid) && child.pid > 1) return;
+ await new Promise((resolve, reject) => {
+ child.once('spawn', resolve);
+ child.once('error', reject);
+ });
+}
+
+async function stopSelfTestChild(child) {
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
+ child.kill('SIGKILL');
+ await waitForChildClose(child, 10_000);
+}
+
+async function validateWaitForChildCloseHardTimeout(killBehavior) {
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
+ stdio: 'ignore',
+ windowsHide: true,
+ });
+ await waitForSelfTestChildSpawn(child);
+ const originalKill = child.kill.bind(child);
+ child.kill = () => {
+ if (killBehavior === 'false') return false;
+ const error = new Error('synthetic-child-kill-failure');
+ error.code = 'EPERM';
+ throw error;
+ };
+ const startedAt = Date.now();
+ let failure = null;
+ try {
+ await waitForChildClose(child, 25, 75);
+ } catch (error) {
+ failure = error;
+ } finally {
+ child.kill = originalKill;
+ await stopSelfTestChild(child);
+ }
+ assert(
+ failure?.code ===
+ 'isolated-runner-stable-kill-handle-helper-reap-timeout' &&
+ Date.now() - startedAt < 2_000,
+ `agent-runtime-real-e2e-self-test-child-close-${killBehavior}-kill-hard-timeout-invalid`,
+ );
+ return true;
+}
+
+async function validateStableKillHandleCommandFailureCleanup() {
+ const victim = spawn(
+ process.execPath,
+ ['-e', 'setInterval(() => {}, 1000)'],
+ { stdio: 'ignore', windowsHide: true },
+ );
+ await waitForSelfTestChildSpawn(victim);
+ const handle = await openOwnedRunnerKillHandle(victim.pid);
+ const originalEnd = handle.child.stdin.end.bind(handle.child.stdin);
+ const originalKill = handle.child.kill.bind(handle.child);
+ let helperClosedWhenCleanupKillStarted = null;
+ let failure = null;
+ handle.child.stdin.end = () => {
+ const error = new Error('synthetic-stable-handle-epipe');
+ error.code = 'EPIPE';
+ throw error;
+ };
+ handle.child.kill = (...args) => {
+ helperClosedWhenCleanupKillStarted ??= handle.closed;
+ return originalKill(...args);
+ };
+ try {
+ await closeOwnedRunnerKillHandle(handle);
+ } catch (error) {
+ failure = error;
+ } finally {
+ handle.child.stdin.end = originalEnd;
+ handle.child.kill = originalKill;
+ }
+ try {
+ assert(
+ failure?.code === 'isolated-runner-stable-kill-handle-stdin-failed' &&
+ handle.command === 'CLOSE' &&
+ helperClosedWhenCleanupKillStarted === false &&
+ handle.closed === true &&
+ (handle.child.exitCode !== null || handle.child.signalCode !== null) &&
+ !activeCommandChildren.has(handle.child) &&
+ isProcessAlive(victim.pid),
+ 'agent-runtime-real-e2e-self-test-stable-handle-command-failure-cleanup-invalid',
+ );
+ return true;
+ } finally {
+ await closeOwnedRunnerKillHandle(handle).catch(() => {});
+ await stopSelfTestChild(victim);
+ }
+}
+
+async function validateInteractiveCliInheritedStdioLifecycle() {
+ const expectedReport = {
+ schemaVersion: 'game-creator-swarm-turn-report.v1',
+ outcome: 'settled',
+ parentAgentId: 'project-supervisor',
+ sessionId: 'interactive-cli-fd3-self-test-session',
+ parentRunId: 'interactive-cli-fd3-self-test-run',
+ runtimeCount: 1,
+ busyRuntimeCount: 0,
+ pendingTaskCount: 0,
+ runningTaskCount: 0,
+ waitingForConfirmationCount: 0,
+ waitingForUserInputCount: 0,
+ newAssistantMessageCount: 1,
+ finalReplyChars: 1,
+ reconciliationAgentCount: 0,
+ };
+ const stderrMarker = 'interactive-cli-fd3-holder-stderr-marker';
+ const previousProjectPathScanner = state.projectPathTranscriptScanner;
+ const projectPathScanner = new StreamingSecretScanner([stderrMarker]);
+ state.projectPathTranscriptScanner = projectPathScanner;
+ const reportLine = `[turn.report] ${JSON.stringify(expectedReport)}\n`;
+ const splitIndex = Math.max(1, Math.floor(reportLine.length / 2));
+ const holderSource = `
+process.stdin.setEncoding('utf8');
+let input = '';
+const safetyTimer = setTimeout(() => process.exit(74), 15_000);
+process.stdin.on('data', (chunk) => {
+ input += chunk;
+ if (!input.includes('CLOSE\\n')) return;
+ clearTimeout(safetyTimer);
+ process.exit(0);
+});
+process.stdin.on('end', () => process.exit(75));
+process.stdin.resume();
+`;
+ const wrapperSource = `
+const { spawn } = require('node:child_process');
+const holder = spawn(
+ process.execPath,
+ ['--input-type=commonjs', '-e', ${JSON.stringify(holderSource)}],
+ { stdio: [3, 'inherit', 'inherit'], windowsHide: true },
+);
+holder.once('error', () => {
+ process.exitCode = 73;
+});
+holder.unref();
+const reportLine = ${JSON.stringify(reportLine)};
+process.stdout.write(reportLine.slice(0, ${splitIndex}), () => {
+ setImmediate(() => {
+ process.stdout.write(reportLine.slice(${splitIndex}), () => {
+ process.stderr.write(${JSON.stringify(`${stderrMarker}\n`)}, () => {
+ process.exit(process.exitCode ?? 0);
+ });
+ });
+ });
+});
+`;
+ const wrapper = spawn(
+ process.execPath,
+ ['--input-type=commonjs', '-e', wrapperSource],
+ {
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ },
+ );
+ const session = createInteractiveCliSession(wrapper);
+ const originalKill = wrapper.kill.bind(wrapper);
+ let killCallCount = 0;
+ wrapper.kill = (...args) => {
+ killCallCount += 1;
+ return originalKill(...args);
+ };
+ let controlStreamError = null;
+ wrapper.stdio[3].on('error', (error) => {
+ controlStreamError ??= error;
+ });
+ try {
+ await waitForInteractiveCliExit(session, 10_000);
+ assert(
+ session.exited && !session.stdioClosed,
+ 'agent-runtime-real-e2e-self-test-interactive-cli-two-phase-not-observed',
+ );
+ const parsedReport =
+ await waitForSupervisorAutonomousPlayableCliExit(session);
+ await waitForInteractiveCliOutput(
+ session,
+ () => session.stderr.toString('utf8').includes(`${stderrMarker}\n`),
+ 'agent-runtime-real-e2e-self-test-interactive-cli-output-timeout',
+ 10_000,
+ { allowAfterProcessExit: true },
+ );
+ assert(
+ JSON.stringify(canonicalJsonValue(parsedReport)) ===
+ JSON.stringify(canonicalJsonValue(expectedReport)),
+ 'agent-runtime-real-e2e-self-test-interactive-cli-report-mismatch',
+ );
+ await closeInteractiveCli(session);
+ assert(
+ killCallCount === 0 && !session.stdioClosed,
+ 'agent-runtime-real-e2e-self-test-interactive-cli-exit-cleanup-killed',
+ );
+ wrapper.stdio[3].end('CLOSE\n');
+ await waitForInteractiveCliStdioClose(session, 10_000);
+ assert(
+ controlStreamError === null &&
+ session.stdioClosed &&
+ projectPathScanner.count === 1,
+ 'agent-runtime-real-e2e-self-test-interactive-cli-stdio-close-invalid',
+ );
+ return {
+ interactiveCliExitBeforeStdioCloseValidated: true,
+ interactiveCliPostExitReportValidated: true,
+ interactiveCliExitCleanupDidNotKillValidated: true,
+ interactiveCliStdioCloseAfterHolderReleaseValidated: true,
+ interactiveCliProjectPathScannerValidated: true,
+ };
+ } finally {
+ if (!wrapper.stdio[3].destroyed && !wrapper.stdio[3].writableEnded) {
+ wrapper.stdio[3].end('CLOSE\n');
+ }
+ if (!session.stdioClosed) {
+ await waitForInteractiveCliStdioClose(session, 10_000).catch(() => {
+ destroyInteractiveCliOutputStreams(session);
+ });
+ }
+ state.projectPathTranscriptScanner = previousProjectPathScanner;
+ }
+}
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs
index 7d0acb8f7..18289737e 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs
@@ -15,6 +15,7 @@ import {
duplicateCount,
finalMessageId,
isNonEmptyString,
+ runtimePublicStatusMessageId,
} from '../assertions/runtime.mjs';
import { fs, path } from '../dependencies.mjs';
import {
@@ -29,10 +30,18 @@ import {
interactiveCliOutput,
prepareCliBinary,
startInteractiveCli,
+ waitForInteractiveCliOutput,
} from '../harness/process.mjs';
-import { seedDisposableProject } from '../harness/project.mjs';
+import {
+ productionDefaultGameIndexHtml,
+ seedDisposableProject,
+} from '../harness/project.mjs';
import { decodeUtf8Fatal } from '../harness/reporting.mjs';
-import { findPendingActions, readRuntime } from '../harness/runtime.mjs';
+import {
+ countLureLeaks,
+ findPendingActions,
+ readRuntime,
+} from '../harness/runtime.mjs';
import {
autonomousCompletionContractSchemaVersion,
autonomousGameBuildRunProfile,
@@ -42,6 +51,8 @@ import {
providerRequestLifecycleSchemaVersion,
runProfileBindingSchemaVersion,
state,
+ staticDelegateClaimSchemaVersion,
+ staticDelegateDeliverySchemaVersion,
supervisorAutonomousPlayableLaneDefenseSuite,
supervisorAutonomousPlayableLaneDefenseTask,
supervisorAutonomousPlayableRunTimeoutMs,
@@ -52,6 +63,7 @@ import {
supervisorAutonomousPlayableSafeTurnOutcomes,
supervisorAutonomousPlayableSourceFieldMaxChars,
supervisorAutonomousPlayableSourceTotalMaxChars,
+ supervisorGameChatSingleMainPlayableSuite,
supervisorSwarmSessionId,
supervisorSwarmTerminalSidecarCleanupTimeoutMs,
} from '../runtime-state.mjs';
@@ -65,6 +77,85 @@ import {
validateSupervisorSwarmFinalization,
} from './supervisor-swarm.mjs';
+const projectSupervisorCliSource = 'project-supervisor-cli';
+const projectSupervisorGameChatSource = 'project-supervisor-game-chat';
+const projectSupervisorAcceptedPublicStatus = 'accepted';
+const projectSupervisorAcceptedPublicStatusContent =
+ '任务已接收,项目总控 Agent 正在启动处理。';
+const gameChatMainAgentId = 'code-prototype';
+const gameChatArtAgentIds = ['art-director', 'art-asset-plan'];
+const gameChatArtSlotToAgent = new Map([
+ ['art-spec', 'art-director'],
+ ['core-spritesheet', 'art-asset-plan'],
+]);
+const gameChatArtExpectedArtifact = new Map([
+ ['art-director', 'assets/art-spec.png'],
+ ['art-asset-plan', 'assets/art-spritesheet.png'],
+]);
+
+export function isSupervisorAutonomousPlayableAcceptedPublicStatus(
+ message,
+ runId,
+) {
+ return (
+ isPlainObject(message) &&
+ message.role === 'assistant' &&
+ message.agentId == null &&
+ message.sessionId == null &&
+ message.messageId ===
+ runtimePublicStatusMessageId(
+ projectSupervisorAgentId,
+ supervisorSwarmSessionId,
+ runId,
+ projectSupervisorAcceptedPublicStatus,
+ ) &&
+ message.content === projectSupervisorAcceptedPublicStatusContent
+ );
+}
+
+export function inspectSupervisorAutonomousPlayableConversationBoundary(
+ legacyConversation,
+ userFacing,
+ runId,
+) {
+ const legacyUserFacingMessages = legacyConversation.filter((message) =>
+ ['user', 'assistant'].includes(message.role),
+ );
+ const acceptedPublicStatuses = legacyUserFacingMessages.filter((message) =>
+ isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId),
+ );
+ const wrongAgentFinalAssistants = userFacing.filter(
+ (message) =>
+ message.role === 'assistant' &&
+ message.agentId !== projectSupervisorAgentId &&
+ !isSupervisorAutonomousPlayableAcceptedPublicStatus(message, runId),
+ );
+ return {
+ legacyUserFacingMessageCount: legacyUserFacingMessages.length,
+ acceptedPublicStatusCount: acceptedPublicStatuses.length,
+ wrongAgentFinalAssistantCount: wrongAgentFinalAssistants.length,
+ };
+}
+
+export function supervisorAutonomousPlayableMode() {
+ if (state.suite === supervisorGameChatSingleMainPlayableSuite) {
+ return {
+ gameChatSingleMain: true,
+ rootSource: projectSupervisorGameChatSource,
+ evidenceAgentId: gameChatMainAgentId,
+ scenario: 'project-supervisor-game-chat-single-main-playable',
+ cliFlags: ['--game-chat-smoke'],
+ };
+ }
+ return {
+ gameChatSingleMain: false,
+ rootSource: projectSupervisorCliSource,
+ evidenceAgentId: projectSupervisorAgentId,
+ scenario: 'project-supervisor-autonomous-playable-lane-defense',
+ cliFlags: [],
+ };
+}
+
export function buildSupervisorAutonomousPlayableStdin() {
const task = supervisorAutonomousPlayableLaneDefenseTask;
assert(
@@ -133,22 +224,33 @@ export async function waitForSupervisorAutonomousPlayableCliExit(session) {
});
let result;
try {
- result = await Promise.race([session.closePromise, timeout]);
+ result = await Promise.race([session.exitPromise, timeout]);
} finally {
clearTimeout(timeoutHandle);
}
if (!result) {
throw codedError('supervisor-autonomous-playable-cli-timeout');
}
- const output = interactiveCliOutput(session);
- state.supervisorAutonomousPlayable.cliOutput = output;
if (result.error || result.code !== 0 || result.signal !== null) {
+ state.supervisorAutonomousPlayable.cliOutput =
+ interactiveCliOutput(session);
throw codedProcessError('supervisor-autonomous-playable-cli-failed', {
...result,
stdout: session.stdout,
stderr: session.stderr,
});
}
+ const output = await waitForInteractiveCliOutput(
+ session,
+ () =>
+ /(?:^|\r?\n)\[turn\.report\] [^\r\n]+\r?\n/u.test(
+ session.stdout.toString('utf8'),
+ ),
+ 'supervisor-autonomous-playable-turn-report-timeout',
+ 10_000,
+ { allowAfterProcessExit: true },
+ );
+ state.supervisorAutonomousPlayable.cliOutput = output;
const report = parseSingleSwarmTurnReport(
output,
'supervisor-autonomous-playable',
@@ -267,6 +369,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence(
partialEvidenceReadErrorCount = 0,
} = {},
) {
+ const productionDefaultGameIndexSha256 = hashValue(
+ Buffer.from(productionDefaultGameIndexHtml(), 'utf8'),
+ );
const parentTask = persistence.taskSnapshot.latest.find(
(task) =>
task.agentId === projectSupervisorAgentId &&
@@ -386,6 +491,9 @@ export function buildSupervisorAutonomousPlayablePartialEvidence(
summarizeSupervisorAutonomousPlayableSourcePayloadAudits(
persistence.agentDb,
);
+ const providerBinding =
+ state.supervisorAutonomousPlayable.effectiveProviderBinding ??
+ state.supervisorAutonomousPlayable.expectedProviderBinding;
return {
evidenceCompleteness: 'partial',
@@ -457,6 +565,20 @@ export function buildSupervisorAutonomousPlayablePartialEvidence(
stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded,
stdinBytes: state.supervisorAutonomousPlayable.stdinBytes,
taskSha256: state.initialTask?.sha256 ?? null,
+ providerAgentMode: providerBinding?.providerAgentMode ?? null,
+ providerModel: providerBinding?.providerModel ?? null,
+ providerApiKind: providerBinding?.providerApiKind ?? null,
+ providerReasoningEffort: providerBinding?.providerReasoningEffort ?? null,
+ providerBaseUrlSha256: providerBinding?.providerBaseUrlSha256 ?? null,
+ providerBoundAgentCount: providerBinding?.boundAgentIds?.length ?? 0,
+ providerBindingMatched:
+ state.supervisorAutonomousPlayable.effectiveProviderBinding != null &&
+ JSON.stringify(
+ state.supervisorAutonomousPlayable.effectiveProviderBinding,
+ ) ===
+ JSON.stringify(
+ state.supervisorAutonomousPlayable.expectedProviderBinding,
+ ),
turnReportCaptured,
turnReportParentIdentityStable,
turnReportNewAssistantMessageCount:
@@ -563,6 +685,11 @@ export function buildSupervisorAutonomousPlayablePartialEvidence(
finalizationJournalCount: residualSidecars.finalizations ?? 0,
reconciliationResidueCount:
supervisorAutonomousPlayableReconciliationResidueCount(persistence),
+ freshInitBaselineUsed:
+ state.supervisorAutonomousPlayable.freshInitBaselineUsed,
+ initialGameIndexMatchesProductionDefault:
+ state.supervisorAutonomousPlayable.initialGameIndexSha256 ===
+ productionDefaultGameIndexSha256,
initialGameIndexSha256:
state.supervisorAutonomousPlayable.initialGameIndexSha256,
};
@@ -771,11 +898,383 @@ export async function waitForSupervisorAutonomousPlayableDurableQuiescence() {
throw codedError('supervisor-autonomous-playable-residue-timeout');
}
+export function validateSupervisorGameChatSingleMainTopology(
+ persistence,
+ runProfileBindings,
+ { workflowDecision, assetCoverage, assetRoute },
+) {
+ const tasks = persistence.taskSnapshot.latest;
+ const runtimes = persistence.runtimeStates;
+ const runKey = (record) => `${record.agentId}\0${record.runId}`;
+ const parentKey = (record) =>
+ isNonEmptyString(record.parentAgentId) &&
+ isNonEmptyString(record.parentRunId)
+ ? `${record.parentAgentId}\0${record.parentRunId}`
+ : null;
+ const rootKey = `${projectSupervisorAgentId}\0${state.initialRunId}`;
+ const rootTasks = tasks.filter(
+ (candidate) =>
+ candidate.agentId === projectSupervisorAgentId &&
+ candidate.parentAgentId == null &&
+ candidate.parentRunId == null,
+ );
+ const rootRuntimes = runtimes.filter(
+ (candidate) =>
+ candidate.agentId === projectSupervisorAgentId &&
+ candidate.parentAgentId == null &&
+ candidate.parentRunId == null,
+ );
+ assert(
+ rootTasks.length === 1 &&
+ rootRuntimes.length === 1 &&
+ runKey(rootTasks[0]) === rootKey &&
+ runKey(rootRuntimes[0]) === rootKey,
+ 'supervisor-game-chat-single-main-root-run-not-unique',
+ );
+
+ const tasksByKey = new Map();
+ const runtimesByKey = new Map();
+ for (const task of tasks) {
+ assert(
+ isNonEmptyString(task.agentId) &&
+ isNonEmptyString(task.runId) &&
+ !tasksByKey.has(runKey(task)),
+ 'supervisor-game-chat-single-main-task-identity-duplicate',
+ );
+ tasksByKey.set(runKey(task), task);
+ }
+ for (const runtime of runtimes) {
+ assert(
+ isNonEmptyString(runtime.agentId) &&
+ isNonEmptyString(runtime.runId) &&
+ !runtimesByKey.has(runKey(runtime)),
+ 'supervisor-game-chat-single-main-runtime-identity-duplicate',
+ );
+ runtimesByKey.set(runKey(runtime), runtime);
+ }
+ const childrenByParent = new Map();
+ for (const task of tasks) {
+ const parent = parentKey(task);
+ if (!parent) continue;
+ const children = childrenByParent.get(parent) ?? [];
+ children.push(task);
+ childrenByParent.set(parent, children);
+ }
+ const reachableKeys = new Set();
+ const queue = [rootTasks[0]];
+ const artTasks = [];
+ while (queue.length > 0) {
+ const task = queue.shift();
+ const key = runKey(task);
+ assert(
+ !reachableKeys.has(key),
+ 'supervisor-game-chat-single-main-task-cycle-detected',
+ );
+ reachableKeys.add(key);
+ const runtime = runtimesByKey.get(key);
+ assert(
+ task.status === 'completed' &&
+ task.phase === 'completed' &&
+ runtime?.phase === 'completed' &&
+ runtime.pendingToolAction == null &&
+ runtime.pendingAction == null,
+ 'supervisor-game-chat-single-main-descendant-not-completed',
+ );
+ const children = childrenByParent.get(key) ?? [];
+ if (task.agentId === projectSupervisorAgentId) {
+ assert(
+ children.length === 1 &&
+ children[0].agentId === gameChatMainAgentId &&
+ children[0].source === 'agent-ready-task-scheduler',
+ 'supervisor-game-chat-single-main-code-child-invalid',
+ );
+ } else if (task.agentId === gameChatMainAgentId) {
+ assert(
+ children.length <= gameChatArtAgentIds.length &&
+ children.every(
+ (child) =>
+ gameChatArtAgentIds.includes(child.agentId) &&
+ child.source === 'agent-delegate',
+ ) &&
+ new Set(children.map((child) => child.agentId)).size ===
+ children.length,
+ 'supervisor-game-chat-single-main-art-child-topology-invalid',
+ );
+ artTasks.push(...children);
+ } else {
+ assert(
+ gameChatArtAgentIds.includes(task.agentId) && children.length === 0,
+ 'supervisor-game-chat-single-main-nested-child-forbidden',
+ );
+ }
+ queue.push(...children);
+ }
+ assert(
+ reachableKeys.size === tasks.length &&
+ reachableKeys.size === runtimes.length &&
+ [...runtimesByKey].every(
+ ([key, runtime]) =>
+ reachableKeys.has(key) &&
+ parentKey(runtime) === parentKey(tasksByKey.get(key)),
+ ),
+ 'supervisor-game-chat-single-main-unreachable-run-detected',
+ );
+
+ const bindingGroups = new Map();
+ for (const binding of runProfileBindings) {
+ const key = runKey(binding);
+ const group = bindingGroups.get(key) ?? [];
+ group.push(binding);
+ bindingGroups.set(key, group);
+ }
+ assert(
+ runProfileBindings.length === reachableKeys.size &&
+ bindingGroups.size === reachableKeys.size &&
+ [...bindingGroups.keys()].every((key) => reachableKeys.has(key)),
+ 'supervisor-game-chat-single-main-binding-set-invalid',
+ );
+ for (const key of reachableKeys) {
+ const task = tasksByKey.get(key);
+ const binding = bindingGroups.get(key)?.[0];
+ const parentTask = parentKey(task) ? tasksByKey.get(parentKey(task)) : null;
+ const parentBinding = parentTask
+ ? bindingGroups.get(runKey(parentTask))?.[0]
+ : null;
+ assert(
+ bindingGroups.get(key)?.length === 1 &&
+ isNonEmptyString(binding?.bindingFingerprint) &&
+ binding.source === task.source &&
+ binding?.profile === autonomousGameBuildRunProfile &&
+ binding.rootAgentId === projectSupervisorAgentId &&
+ binding.rootRunId === state.initialRunId &&
+ (parentTask
+ ? isNonEmptyString(parentBinding?.bindingFingerprint) &&
+ binding.parentAgentId === parentTask.agentId &&
+ binding.parentRunId === parentTask.runId &&
+ binding.parentBindingFingerprint ===
+ parentBinding?.bindingFingerprint
+ : binding.parentAgentId == null &&
+ binding.parentRunId == null &&
+ binding.parentBindingFingerprint == null),
+ 'supervisor-game-chat-single-main-binding-chain-invalid',
+ );
+ }
+
+ const codeTask = tasks.find(
+ (task) =>
+ task.agentId === gameChatMainAgentId &&
+ task.parentAgentId === projectSupervisorAgentId &&
+ task.parentRunId === state.initialRunId,
+ );
+ assert(
+ workflowDecision?.rootAgentId === projectSupervisorAgentId &&
+ workflowDecision.rootRunId === state.initialRunId &&
+ workflowDecision.strategy === 'audit-existing-first' &&
+ assetCoverage?.rootRunId === state.initialRunId &&
+ assetCoverage.auditedByAgentId === gameChatMainAgentId &&
+ assetCoverage.auditedByRunId === codeTask.runId &&
+ JSON.stringify(assetCoverage.requiredSlots) ===
+ JSON.stringify([...gameChatArtSlotToAgent.keys()]) &&
+ Array.isArray(assetCoverage.missingSlots) &&
+ new Set(assetCoverage.missingSlots).size ===
+ assetCoverage.missingSlots.length &&
+ assetCoverage.missingSlots.every((slot) =>
+ gameChatArtSlotToAgent.has(slot),
+ ) &&
+ assetRoute?.rootRunId === state.initialRunId,
+ 'supervisor-game-chat-single-main-asset-route-binding-invalid',
+ );
+ const expectedGeneratedAgentIds = assetCoverage.missingSlots.map((slot) =>
+ gameChatArtSlotToAgent.get(slot),
+ );
+ const expectedReusedAgentIds = [...gameChatArtSlotToAgent]
+ .filter(([slot]) => !assetCoverage.missingSlots.includes(slot))
+ .map(([, agentId]) => agentId);
+ assert(
+ assetRoute.strategy ===
+ (expectedGeneratedAgentIds.length === 0
+ ? 'use-existing-art'
+ : 'generate-missing-art') &&
+ JSON.stringify(assetRoute.generatedTaskIds) ===
+ JSON.stringify(expectedGeneratedAgentIds) &&
+ JSON.stringify(assetRoute.reusedTaskIds) ===
+ JSON.stringify(expectedReusedAgentIds) &&
+ JSON.stringify(artTasks.map((task) => task.agentId).sort()) ===
+ JSON.stringify([...expectedGeneratedAgentIds].sort()),
+ 'supervisor-game-chat-single-main-art-gap-mismatch',
+ );
+
+ const actionReceiptIndexes = new Map();
+ persistence.agentDb.forEach((record, index) => {
+ if (
+ record.recordType === 'agent.runtime.action_receipt' &&
+ record.agentId === gameChatMainAgentId &&
+ record.runId === codeTask.runId &&
+ isNonEmptyString(record.actionId)
+ ) {
+ actionReceiptIndexes.set(record.actionId, { record, index });
+ }
+ });
+ const assetListIndexes = [...actionReceiptIndexes.values()]
+ .filter(
+ ({ record }) => record.tool === 'asset.list' && record.status === 'ok',
+ )
+ .map(({ index }) => index);
+ assert(
+ assetListIndexes.length >= 1,
+ 'supervisor-game-chat-single-main-asset-list-evidence-missing',
+ );
+ const artDeliveryByAgent = new Map();
+ for (const artTask of artTasks) {
+ const expectedArtifact = gameChatArtExpectedArtifact.get(artTask.agentId);
+ const deliveries = persistence.deliveries.filter(
+ (delivery) =>
+ delivery.parentAgentId === gameChatMainAgentId &&
+ delivery.parentRunId === codeTask.runId &&
+ delivery.targetAgentId === artTask.agentId &&
+ delivery.targetRunId === artTask.runId &&
+ delivery.delegationId === artTask.delegationId,
+ );
+ const delivery = deliveries[0];
+ const delegateReceipt = actionReceiptIndexes.get(delivery?.parentActionId);
+ const claims = persistence.claims.filter((claim) =>
+ (claim.receipts ?? []).some(
+ (receipt) => receipt.delegationId === delivery?.delegationId,
+ ),
+ );
+ const claim = claims[0];
+ const claimReceipt = actionReceiptIndexes.get(claim?.actionId);
+ const claimedReceipt = claim?.receipts?.find(
+ (candidate) => candidate.delegationId === delivery?.delegationId,
+ );
+ const resultArtifacts = delivery?.structuredResult?.artifacts;
+ assert(
+ deliveries.length === 1 &&
+ delivery.schemaVersion === staticDelegateDeliverySchemaVersion &&
+ delivery.status === 'claimed-by-parent' &&
+ delivery.terminalStatus === 'completed' &&
+ delivery.repairOfDelegationId == null &&
+ JSON.stringify(delivery.expectedArtifacts) ===
+ JSON.stringify([expectedArtifact]) &&
+ delivery.structuredResult?.contractStatus === 'evidence-ready' &&
+ Array.isArray(resultArtifacts) &&
+ resultArtifacts.length > 0 &&
+ resultArtifacts.some(
+ (artifact) => artifact.path === expectedArtifact,
+ ) &&
+ resultArtifacts.every(
+ (artifact) =>
+ isNonEmptyString(artifact.path) &&
+ !path.isAbsolute(artifact.path) &&
+ artifact.path.startsWith('assets/') &&
+ !artifact.path.split('/').includes('..') &&
+ /^[0-9a-f]{64}$/u.test(artifact.sha256),
+ ) &&
+ delegateReceipt?.record.tool === 'agent.delegate' &&
+ delegateReceipt.record.status === 'ok' &&
+ assetListIndexes.some((index) => index < delegateReceipt.index) &&
+ claims.length === 1 &&
+ claim.schemaVersion === staticDelegateClaimSchemaVersion &&
+ claim.parentAgentId === gameChatMainAgentId &&
+ claim.parentRunId === codeTask.runId &&
+ claim.status === 'observed' &&
+ claimReceipt?.record.tool === 'agent.run_status' &&
+ claimReceipt.record.status === 'ok' &&
+ delivery.claimedByActionId === claim.actionId &&
+ claimedReceipt?.targetAgentId === artTask.agentId &&
+ claimedReceipt.status === delivery.terminalStatus,
+ 'supervisor-game-chat-single-main-art-delivery-invalid',
+ );
+ artDeliveryByAgent.set(artTask.agentId, {
+ delivery,
+ delegateReceiptIndex: delegateReceipt.index,
+ claimReceiptIndex: claimReceipt.index,
+ });
+ }
+ if (
+ artDeliveryByAgent.has('art-director') &&
+ artDeliveryByAgent.has('art-asset-plan')
+ ) {
+ assert(
+ artDeliveryByAgent.get('art-director').claimReceiptIndex >= 0 &&
+ artDeliveryByAgent.get('art-director').claimReceiptIndex <
+ artDeliveryByAgent.get('art-asset-plan').delegateReceiptIndex,
+ 'supervisor-game-chat-single-main-art-order-invalid',
+ );
+ }
+ return {
+ uniqueRootRunCount: rootTasks.length,
+ recursiveDescendantCount: reachableKeys.size - 1,
+ completedDescendantCount: reachableKeys.size - 1,
+ artChildCount: artTasks.length,
+ artDeliveryClaimCount: artDeliveryByAgent.size,
+ assetListBeforeArtDelegation: true,
+ };
+}
+
+export function supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex(
+ gate,
+ revision,
+ finalGameIndexSha256,
+) {
+ return (
+ gate?.lastVerificationStatus === 'passed' &&
+ gate.verifiedRevision === revision &&
+ gate.staticSmokeVerifiedRevision === revision &&
+ gate.staticSmokeVerifiedGameIndexSha256 === finalGameIndexSha256
+ );
+}
+
+export function supervisorGameChatFreshInitAcceptanceEvidence({
+ freshInitBaselineUsed,
+ initialGameIndexSha256,
+ productionDefaultGameIndexSha256,
+ uniqueFixedChildCodePrototype,
+ finalGameIndexSha256,
+ gate,
+ revision,
+ viewportResults,
+}) {
+ return {
+ freshInitBaselineUsed: freshInitBaselineUsed === true,
+ initialGameIndexMatchesProductionDefault:
+ /^[0-9a-f]{64}$/u.test(initialGameIndexSha256 ?? '') &&
+ initialGameIndexSha256 === productionDefaultGameIndexSha256,
+ uniqueFixedChildCodePrototype: uniqueFixedChildCodePrototype === true,
+ finalGameIndexDiffersFromBaseline:
+ /^[0-9a-f]{64}$/u.test(finalGameIndexSha256 ?? '') &&
+ finalGameIndexSha256 !== initialGameIndexSha256,
+ staticSmokeGameIndexSha256Matched:
+ supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex(
+ gate,
+ revision,
+ finalGameIndexSha256,
+ ),
+ desktopPlaytestPassed:
+ viewportResults?.find((entry) => entry.viewport === 'desktop')?.passed ===
+ true,
+ mobilePlaytestPassed:
+ viewportResults?.find((entry) => entry.viewport === 'mobile')?.passed ===
+ true,
+ };
+}
+
export async function validateSupervisorAutonomousPlayableEvidence(
persistence,
residualSidecars,
) {
const task = supervisorAutonomousPlayableLaneDefenseTask;
+ const mode = supervisorAutonomousPlayableMode();
+ const productionDefaultGameIndex = Buffer.from(
+ productionDefaultGameIndexHtml(),
+ 'utf8',
+ );
+ const productionDefaultGameIndexSha256 = hashValue(
+ productionDefaultGameIndex,
+ );
+ const initialGameIndexMatchesProductionDefault =
+ state.supervisorAutonomousPlayable.initialGameIndexSha256 ===
+ productionDefaultGameIndexSha256;
const report = state.supervisorAutonomousPlayable.turnReport;
assert(
report?.schemaVersion === 'game-creator-swarm-turn-report.v1' &&
@@ -814,7 +1313,7 @@ export async function validateSupervisorAutonomousPlayableEvidence(
);
assert(
parentTask?.task === task &&
- parentTask.source === 'project-supervisor-cli' &&
+ parentTask.source === mode.rootSource &&
parentTask.status === 'completed' &&
parentTask.phase === 'completed' &&
parentTask.runProfile === autonomousGameBuildRunProfile &&
@@ -861,6 +1360,12 @@ export async function validateSupervisorAutonomousPlayableEvidence(
...persistence.supervisorConversation,
...persistence.legacyConversation,
];
+ const conversationBoundary =
+ inspectSupervisorAutonomousPlayableConversationBoundary(
+ persistence.legacyConversation,
+ userFacing,
+ state.initialRunId,
+ );
const allMessages = [
...userFacing,
...professionalMessages,
@@ -894,14 +1399,9 @@ export async function validateSupervisorAutonomousPlayableEvidence(
supervisorSwarmSessionId,
state.initialRunId,
) &&
- persistence.legacyConversation.filter((message) =>
- ['user', 'assistant'].includes(message.role),
- ).length === 0 &&
- userFacing.filter(
- (message) =>
- message.role === 'assistant' &&
- message.agentId !== projectSupervisorAgentId,
- ).length === 0 &&
+ conversationBoundary.legacyUserFacingMessageCount === 1 &&
+ conversationBoundary.acceptedPublicStatusCount === 1 &&
+ conversationBoundary.wrongAgentFinalAssistantCount === 0 &&
parentAssistantAudits.length === 1 &&
parentCompletedAudits.length === 1 &&
duplicateMessageCount === 0,
@@ -996,7 +1496,7 @@ export async function validateSupervisorAutonomousPlayableEvidence(
binding.rootRunId === state.initialRunId &&
binding.parentAgentId == null &&
binding.parentRunId == null &&
- binding.source === 'project-supervisor-cli' &&
+ binding.source === mode.rootSource &&
binding.profile === autonomousGameBuildRunProfile &&
binding.profileFingerprint ===
hashJsonValue({
@@ -1019,6 +1519,94 @@ export async function validateSupervisorAutonomousPlayableEvidence(
);
const contract = contracts[0];
const receipt = receipts[0];
+ let evidenceBinding = binding;
+ let evidenceRunId = state.initialRunId;
+ let gameChatTopologyEvidence = {};
+ let uniqueFixedChildCodePrototype = false;
+ if (mode.gameChatSingleMain) {
+ const manifest = JSON.parse(
+ decodeUtf8Fatal(
+ await fs.readFile(path.join(state.projectRoot, '.agent/manifest.json')),
+ 'supervisor-autonomous-playable-manifest-invalid-utf8',
+ ),
+ );
+ const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : [];
+ assert(
+ tasks.length === 1 &&
+ tasks[0]?.id === gameChatMainAgentId &&
+ tasks[0]?.status === 'completed',
+ 'supervisor-game-chat-single-main-manifest-invalid',
+ );
+ const rootChildTasks = persistence.taskSnapshot.latest.filter(
+ (candidate) =>
+ candidate.parentAgentId === projectSupervisorAgentId &&
+ candidate.parentRunId === state.initialRunId,
+ );
+ const rootChildRuntimes = persistence.runtimeStates.filter(
+ (candidate) =>
+ candidate.parentAgentId === projectSupervisorAgentId &&
+ candidate.parentRunId === state.initialRunId,
+ );
+ const codeTask = rootChildTasks[0];
+ const codeRuntime = rootChildRuntimes[0];
+ assert(
+ rootChildTasks.length === 1 &&
+ codeTask.agentId === gameChatMainAgentId &&
+ codeTask.status === 'completed' &&
+ codeTask.phase === 'completed' &&
+ codeTask.source === 'agent-ready-task-scheduler' &&
+ rootChildRuntimes.length === 1 &&
+ codeRuntime.agentId === gameChatMainAgentId &&
+ codeRuntime.runId === codeTask.runId &&
+ codeRuntime.phase === 'completed' &&
+ codeRuntime.pendingToolAction == null &&
+ codeRuntime.pendingAction == null,
+ 'supervisor-game-chat-single-main-runtime-invalid',
+ );
+ evidenceRunId = codeTask.runId;
+ const codeBindings = runProfileBindings.filter(
+ (candidate) =>
+ candidate.agentId === gameChatMainAgentId &&
+ candidate.runId === evidenceRunId &&
+ candidate.rootAgentId === projectSupervisorAgentId &&
+ candidate.rootRunId === state.initialRunId &&
+ candidate.parentAgentId === projectSupervisorAgentId &&
+ candidate.parentRunId === state.initialRunId &&
+ candidate.profile === autonomousGameBuildRunProfile &&
+ candidate.source === 'agent-ready-task-scheduler' &&
+ candidate.parentBindingFingerprint === binding.bindingFingerprint,
+ );
+ assert(
+ codeBindings.length === 1,
+ 'supervisor-game-chat-single-main-binding-invalid',
+ );
+ uniqueFixedChildCodePrototype = true;
+ evidenceBinding = codeBindings[0];
+ const [workflowDecisions, assetCoverages, assetRoutes] = await Promise.all([
+ readSupervisorSwarmJsonDirectory(
+ '.agent/runtime/game-chat-workflow-decisions',
+ ),
+ readSupervisorSwarmJsonDirectory(
+ '.agent/runtime/game-chat-asset-coverage',
+ ),
+ readSupervisorSwarmJsonDirectory('.agent/runtime/game-chat-asset-routes'),
+ ]);
+ assert(
+ workflowDecisions.length === 1 &&
+ assetCoverages.length === 1 &&
+ assetRoutes.length === 1,
+ 'supervisor-game-chat-single-main-asset-sidecar-count-invalid',
+ );
+ gameChatTopologyEvidence = validateSupervisorGameChatSingleMainTopology(
+ persistence,
+ runProfileBindings,
+ {
+ workflowDecision: workflowDecisions[0],
+ assetCoverage: assetCoverages[0],
+ assetRoute: assetRoutes[0],
+ },
+ );
+ }
const contractIdentity = {
schemaVersion: contract.schemaVersion,
projectId: contract.projectId,
@@ -1048,6 +1636,11 @@ export async function validateSupervisorAutonomousPlayableEvidence(
state.supervisorAutonomousPlayable.initialGameIndexSha256 &&
Number.isInteger(contract.baselineArtifacts[0]?.sizeBytes) &&
contract.baselineArtifacts[0].sizeBytes > 0 &&
+ (!mode.gameChatSingleMain ||
+ (state.supervisorAutonomousPlayable.freshInitBaselineUsed === true &&
+ initialGameIndexMatchesProductionDefault &&
+ contract.baselineArtifacts[0].sizeBytes ===
+ productionDefaultGameIndex.length)) &&
contract.playtestScenario === 'lane-defense-v1' &&
contract.contractFingerprint === hashJsonValue(contractIdentity),
'supervisor-autonomous-playable-completion-contract-invalid',
@@ -1072,23 +1665,24 @@ export async function validateSupervisorAutonomousPlayableEvidence(
await readSupervisorSwarmJsonDirectory('.agent/runtime/verification')
).filter(
(gate) =>
- gate.agentId === projectSupervisorAgentId &&
- gate.runId === state.initialRunId,
+ gate.agentId === mode.evidenceAgentId && gate.runId === evidenceRunId,
);
const gate = gates[0];
const staticSmokeAudits = persistence.agentDb.filter(
(record) =>
record.recordType === 'agent.runtime.command.run_limited' &&
- record.agentId === projectSupervisorAgentId &&
- record.runId === state.initialRunId &&
+ record.agentId === mode.evidenceAgentId &&
+ record.runId === evidenceRunId &&
record.commandId === 'game.static_smoke' &&
record.status === 'completed',
);
assert(
gates.length === 1 &&
- gate.lastVerificationTool === 'game.static_smoke' &&
- gate.lastVerificationStatus === 'passed' &&
- gate.verifiedRevision === revision.revision &&
+ supervisorAutonomousPlayableStaticSmokeMatchesFinalIndex(
+ gate,
+ revision.revision,
+ finalGameIndexSha256,
+ ) &&
staticSmokeAudits.length >= 1,
'supervisor-autonomous-playable-static-smoke-invalid',
);
@@ -1112,9 +1706,10 @@ export async function validateSupervisorAutonomousPlayableEvidence(
assert(
receipt.schemaVersion === autonomousPlaytestReceiptSchemaVersion &&
receipt.projectId === contract.projectId &&
- receipt.agentId === projectSupervisorAgentId &&
- receipt.runId === state.initialRunId &&
- receipt.runProfileBindingFingerprint === binding.bindingFingerprint &&
+ receipt.agentId === mode.evidenceAgentId &&
+ receipt.runId === evidenceRunId &&
+ receipt.runProfileBindingFingerprint ===
+ evidenceBinding.bindingFingerprint &&
receipt.revision === revision.revision &&
receipt.gameIndex.path === 'game/index.html' &&
receipt.gameIndex.sha256 === finalGameIndexSha256 &&
@@ -1123,8 +1718,8 @@ export async function validateSupervisorAutonomousPlayableEvidence(
receipt.receiptFingerprint === hashJsonValue(receiptIdentity) &&
actionReceipts.filter(
(record) =>
- record.agentId === projectSupervisorAgentId &&
- record.runId === state.initialRunId &&
+ record.agentId === mode.evidenceAgentId &&
+ record.runId === evidenceRunId &&
record.actionId === receipt.actionId &&
record.actionFingerprint === receipt.actionFingerprint &&
record.tool === 'preview.validate' &&
@@ -1183,6 +1778,12 @@ export async function validateSupervisorAutonomousPlayableEvidence(
const mobile = screenshotEntries.find(({ digest }) =>
digest.path.endsWith('/mobile.png'),
);
+ const desktopViewport = browserReport.viewportResults?.find(
+ (entry) => entry.viewport === 'desktop',
+ );
+ const mobileViewport = browserReport.viewportResults?.find(
+ (entry) => entry.viewport === 'mobile',
+ );
const playtest = browserReport.playtest;
const assertionNames = new Set(
(playtest?.assertions ?? []).map((entry) => entry.name),
@@ -1197,7 +1798,8 @@ export async function validateSupervisorAutonomousPlayableEvidence(
JSON.stringify(
browserReport.viewportResults.map((entry) => entry.viewport).sort(),
) === JSON.stringify(['desktop', 'mobile']) &&
- browserReport.viewportResults.every((entry) => entry.passed === true) &&
+ desktopViewport?.passed === true &&
+ mobileViewport?.passed === true &&
playtest?.scenario === 'lane-defense-v1' &&
playtest.passed === true &&
playtest.scenarioFingerprint === receipt.scenarioFingerprint &&
@@ -1207,6 +1809,26 @@ export async function validateSupervisorAutonomousPlayableEvidence(
),
'supervisor-autonomous-playable-lane-defense-playtest-invalid',
);
+ const gameChatFreshInitAcceptance =
+ supervisorGameChatFreshInitAcceptanceEvidence({
+ freshInitBaselineUsed:
+ state.supervisorAutonomousPlayable.freshInitBaselineUsed,
+ initialGameIndexSha256:
+ state.supervisorAutonomousPlayable.initialGameIndexSha256,
+ productionDefaultGameIndexSha256,
+ uniqueFixedChildCodePrototype,
+ finalGameIndexSha256,
+ gate,
+ revision: revision.revision,
+ viewportResults: browserReport.viewportResults,
+ });
+ assert(
+ !mode.gameChatSingleMain ||
+ Object.values(gameChatFreshInitAcceptance).every(
+ (value) => value === true,
+ ),
+ 'supervisor-game-chat-fresh-init-acceptance-evidence-invalid',
+ );
const lifecycle = persistence.agentDb.filter(
(record) =>
@@ -1299,6 +1921,13 @@ export async function validateSupervisorAutonomousPlayableEvidence(
const publicLeaks = collectSupervisorSwarmPublicLeakEvidence(persistence, {
requireZero: true,
});
+ const projectLureLeakCount = await countLureLeaks();
+ const cliLureLeakCount = countExactSecrets(
+ Buffer.from(state.supervisorAutonomousPlayable.cliOutput),
+ state.lures,
+ );
+ state.lureLeakCount = projectLureLeakCount + cliLureLeakCount;
+ assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected');
state.supervisorAutonomousPlayable.privateValues = [
...new Set(state.supervisorSwarm.privateValues),
];
@@ -1352,6 +1981,22 @@ export async function validateSupervisorAutonomousPlayableEvidence(
turnReport: report,
},
);
+ const providerBinding =
+ state.supervisorAutonomousPlayable.effectiveProviderBinding;
+ assert(
+ providerBinding &&
+ JSON.stringify(providerBinding) ===
+ JSON.stringify(
+ state.supervisorAutonomousPlayable.expectedProviderBinding,
+ ) &&
+ (!isSupervisorGameChatSingleMainPlayableSuite() ||
+ providerBinding.providerAgentMode === 'provider') &&
+ isNonEmptyString(providerBinding.providerModel) &&
+ isNonEmptyString(providerBinding.providerApiKind) &&
+ isNonEmptyString(providerBinding.providerReasoningEffort) &&
+ /^[0-9a-f]{64}$/u.test(providerBinding.providerBaseUrlSha256),
+ 'supervisor-autonomous-playable-provider-binding-invalid',
+ );
return {
...emptySupervisorAutonomousPlayableEvidence(),
@@ -1364,6 +2009,14 @@ export async function validateSupervisorAutonomousPlayableEvidence(
stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded,
stdinBytes: state.supervisorAutonomousPlayable.stdinBytes,
taskSha256: hashValue(task),
+ providerAgentMode: providerBinding.providerAgentMode,
+ providerModel: providerBinding.providerModel,
+ providerApiKind: providerBinding.providerApiKind,
+ providerReasoningEffort: providerBinding.providerReasoningEffort,
+ providerBaseUrlSha256: providerBinding.providerBaseUrlSha256,
+ providerBoundAgentCount: providerBinding.boundAgentIds.length,
+ providerBindingMatched: true,
+ ...gameChatTopologyEvidence,
turnReportCaptured: true,
turnReportParentIdentityStable: true,
turnReportNewAssistantMessageCount: report.newAssistantMessageCount,
@@ -1434,6 +2087,7 @@ export async function validateSupervisorAutonomousPlayableEvidence(
baselineRevision: contract.baselineRevision,
projectRevision: revision.revision,
projectRevisionDelta: revision.revision - contract.baselineRevision,
+ ...gameChatFreshInitAcceptance,
initialGameIndexSha256:
state.supervisorAutonomousPlayable.initialGameIndexSha256,
finalGameIndexSha256,
@@ -1492,12 +2146,37 @@ export async function validateSupervisorAutonomousPlayableEvidence(
export async function runSupervisorAutonomousPlayableLaneDefenseE2e() {
await ensureOwnedRunnerStableKillSupport();
- await seedDisposableProject();
- const initialGameIndex = await fs.readFile(
- path.join(state.projectRoot, 'game/index.html'),
- );
- state.supervisorAutonomousPlayable.initialGameIndexSha256 =
- hashValue(initialGameIndex);
+ const mode = supervisorAutonomousPlayableMode();
+ state.supervisorAutonomousPlayable.freshInitBaselineUsed =
+ mode.gameChatSingleMain;
+ await seedDisposableProject({
+ preserveProductionInitBaseline: mode.gameChatSingleMain,
+ });
+ if (mode.gameChatSingleMain) {
+ const generatedBaselinePaths = [
+ 'package.json',
+ 'verify-e2e.mjs',
+ 'game/index.html',
+ ];
+ const generatedBaselineMetadata = await Promise.all(
+ generatedBaselinePaths.map((relativePath) =>
+ fs.lstat(path.join(state.projectRoot, relativePath)).catch(() => null),
+ ),
+ );
+ assert(
+ generatedBaselineMetadata.every((metadata) => metadata === null),
+ 'supervisor-game-chat-fresh-init-project-not-empty',
+ );
+ state.supervisorAutonomousPlayable.initialGameIndexSha256 = hashValue(
+ Buffer.from(productionDefaultGameIndexHtml(), 'utf8'),
+ );
+ } else {
+ const initialGameIndex = await fs.readFile(
+ path.join(state.projectRoot, 'game/index.html'),
+ );
+ state.supervisorAutonomousPlayable.initialGameIndexSha256 =
+ hashValue(initialGameIndex);
+ }
state.cliBinary = await prepareCliBinary();
await prepareIsolatedSuiteAppData();
state.isolatedRunner.launchAttempted = true;
@@ -1515,6 +2194,7 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() {
'--swarm-chat',
'--init',
'--autonomous-game-build',
+ ...mode.cliFlags,
state.projectRoot,
]);
state.supervisorAutonomousPlayable.stdinWriteCount = 1;
@@ -1529,7 +2209,6 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() {
const report = await waitForSupervisorAutonomousPlayableCliExit(
state.supervisorAutonomousPlayableCliSession,
);
- state.supervisorAutonomousPlayableCliSession = null;
assert(
report.outcome === 'settled',
'supervisor-autonomous-playable-turn-not-settled',
@@ -1546,8 +2225,9 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() {
}
export function emptySupervisorAutonomousPlayableEvidence() {
+ const mode = supervisorAutonomousPlayableMode();
return {
- scenario: 'project-supervisor-autonomous-playable-lane-defense',
+ scenario: mode.scenario,
targetAgentId: projectSupervisorAgentId,
runProfile: autonomousGameBuildRunProfile,
dedicatedZeroInterventionPath: true,
@@ -1560,6 +2240,13 @@ export function emptySupervisorAutonomousPlayableEvidence() {
stdinEndedAfterTask: false,
stdinBytes: 0,
taskSha256: null,
+ providerAgentMode: null,
+ providerModel: null,
+ providerApiKind: null,
+ providerReasoningEffort: null,
+ providerBaseUrlSha256: null,
+ providerBoundAgentCount: 0,
+ providerBindingMatched: false,
evidenceCompleteness: 'none',
partialEvidenceCollected: false,
partialEvidenceReadErrorCount: 0,
@@ -1605,6 +2292,12 @@ export function emptySupervisorAutonomousPlayableEvidence() {
childTaskCount: 0,
runtimeCount: 0,
childRuntimeCount: 0,
+ uniqueRootRunCount: 0,
+ recursiveDescendantCount: 0,
+ completedDescendantCount: 0,
+ artChildCount: 0,
+ artDeliveryClaimCount: 0,
+ assetListBeforeArtDelegation: false,
finalSupervisorAssistantCount: 0,
supervisorUserMessageCount: 0,
professionalAssistantCount: 0,
@@ -1635,11 +2328,16 @@ export function emptySupervisorAutonomousPlayableEvidence() {
baselineRevision: 0,
projectRevision: 0,
projectRevisionDelta: 0,
+ freshInitBaselineUsed: false,
+ initialGameIndexMatchesProductionDefault: false,
initialGameIndexSha256: null,
finalGameIndexSha256: null,
gameIndexChanged: false,
+ finalGameIndexDiffersFromBaseline: false,
+ uniqueFixedChildCodePrototype: false,
gameIndexBytes: 0,
staticSmokePassed: false,
+ staticSmokeGameIndexSha256Matched: false,
staticSmokeAuditCount: 0,
autonomousCompletionContractCount: 0,
autonomousPlaytestReceiptCount: 0,
@@ -1648,6 +2346,8 @@ export function emptySupervisorAutonomousPlayableEvidence() {
laneDefenseAssertionCount: 0,
laneDefensePassedAssertionCount: 0,
browserValidationPassed: false,
+ desktopPlaytestPassed: false,
+ mobilePlaytestPassed: false,
desktopScreenshotSha256: null,
desktopScreenshotBytes: 0,
mobileScreenshotSha256: null,
@@ -1681,6 +2381,20 @@ export function emptySupervisorAutonomousPlayableEvidence() {
supervisorAutonomousPlayableReportLeakCount: 0,
supervisorAutonomousPlayableRunnerStopped: false,
supervisorAutonomousPlayableAppDataCleanupPerformed: false,
+ ownedProcessIdentityCaptured: false,
+ ownedRunnerObservedCount: 0,
+ ownedHelperObservedCount: 0,
+ ownedNodeDescendantObservedCount: 0,
+ ownedBrowserDescendantObservedCount: 0,
+ ownedCommandDescendantObservedCount: 0,
+ ownedRunnerResidualCount: 0,
+ ownedHelperResidualCount: 0,
+ ownedNodeDescendantResidualCount: 0,
+ ownedBrowserDescendantResidualCount: 0,
+ ownedCommandDescendantResidualCount: 0,
+ activeCommandChildrenAfterCleanup: 0,
+ activeInteractiveCliSessionsAfterCleanup: 0,
+ ownedProcessCleanupPassed: false,
secretLeakCount: 0,
lureLeakCount: 0,
paths: [],
@@ -1688,5 +2402,12 @@ export function emptySupervisorAutonomousPlayableEvidence() {
}
export function isSupervisorAutonomousPlayableLaneDefenseSuite() {
- return state.suite === supervisorAutonomousPlayableLaneDefenseSuite;
+ return (
+ state.suite === supervisorAutonomousPlayableLaneDefenseSuite ||
+ state.suite === supervisorGameChatSingleMainPlayableSuite
+ );
+}
+
+export function isSupervisorGameChatSingleMainPlayableSuite() {
+ return state.suite === supervisorGameChatSingleMainPlayableSuite;
}
diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs
index 877a8aa70..278c7acf3 100644
--- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs
+++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-swarm/persistence.mjs
@@ -90,6 +90,37 @@ export async function listOptionalSupervisorSwarmFile(file) {
return [file];
}
+export function supervisorSwarmProfessionalSessions({
+ deliveries = [],
+ latestTasks = [],
+ rootAgentId,
+ rootRunId,
+}) {
+ const sessions = new Map();
+ const registerSession = (agentId, sessionId) => {
+ if (!isNonEmptyString(agentId) || !isNonEmptyString(sessionId)) return;
+ sessions.set(`${agentId}\0${sessionId}`, { agentId, sessionId });
+ };
+ for (const delivery of deliveries) {
+ registerSession(delivery.targetAgentId, delivery.targetSessionId);
+ }
+ if (!isNonEmptyString(rootAgentId) || !isNonEmptyString(rootRunId)) {
+ return [...sessions.values()];
+ }
+ for (const task of latestTasks) {
+ if (
+ task.source !== 'agent-ready-task-scheduler' ||
+ task.agentId === rootAgentId ||
+ task.parentAgentId !== rootAgentId ||
+ task.parentRunId !== rootRunId
+ ) {
+ continue;
+ }
+ registerSession(task.agentId, task.sessionId);
+ }
+ return [...sessions.values()];
+}
+
export async function readSupervisorSwarmPersistence({
tolerateErrors = false,
} = {}) {
@@ -274,25 +305,15 @@ export async function readSupervisorSwarmPersistence({
),
);
const supervisorConversation = supervisorConversationSurface.records;
- const professionalSessions = new Map();
- for (const delivery of deliveries) {
- if (
- !isNonEmptyString(delivery.targetAgentId) ||
- !isNonEmptyString(delivery.targetSessionId)
- ) {
- continue;
- }
- professionalSessions.set(
- `${delivery.targetAgentId}\0${delivery.targetSessionId}`,
- {
- agentId: delivery.targetAgentId,
- sessionId: delivery.targetSessionId,
- },
- );
- }
+ const professionalSessions = supervisorSwarmProfessionalSessions({
+ deliveries,
+ latestTasks: taskSnapshot.latest,
+ rootAgentId: projectSupervisorAgentId,
+ rootRunId: state.initialRunId,
+ });
const professionalConversations = [];
const professionalConversationErrors = [];
- for (const session of professionalSessions.values()) {
+ for (const session of professionalSessions) {
const conversationSurface = await readJsonlSurface(
'professional-conversation',
() =>
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index d2cead3a5..c624b7aa8 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -312,7 +312,9 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) {
function parseAppInvokeCommandNames(source) {
return Array.from(
- source.matchAll(/invoke(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g),
+ source.matchAll(
+ /(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g,
+ ),
([, command]) => command,
);
}
@@ -1164,6 +1166,7 @@ if (
!Array.isArray(tauriConfig.app?.windows) ||
tauriConfig.app.windows.length !== 1 ||
tauriConfig.app.windows[0]?.label !== 'client' ||
+ tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
tauriConfig.app.windows[0]?.url !== 'index.html'
) {
throw new Error(
@@ -1186,11 +1189,12 @@ const allowedLlmReasoningEfforts = new Set([
'low',
'medium',
'high',
+ 'max',
]);
-if (defaultAppConfig.llm?.reasoningEffort !== 'high') {
+if (defaultAppConfig.llm?.reasoningEffort !== 'max') {
throw new Error(
- 'AI game creator shell default llm.reasoningEffort must stay high',
+ 'AI game creator shell default llm.reasoningEffort must stay max',
);
}
@@ -1207,9 +1211,9 @@ for (const [agentId, agentConfig] of Object.entries(
}
}
-if (defaultAppConfig.editorApi?.apiKey !== '') {
+if (defaultAppConfig.editorApi !== undefined) {
throw new Error(
- 'AI game creator shell default editorApi.apiKey must stay empty',
+ 'AI game creator shell ordinary default config must not contain editorApi',
);
}
@@ -1539,7 +1543,7 @@ if (
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
- "response.body.includes('AI 游戏创作 ')",
+ "response.body.includes('陶泥儿 ')",
'function isPortListening()',
'cannot be safely reused',
'non-HTTP or unrecognized server',
@@ -1666,10 +1670,6 @@ if (
for (const snippet of [
'import.meta.env.DEV',
- '#[cfg(all(debug_assertions, not(test)))]',
- 'open_developer_window(app.handle())?',
- 'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())',
- 'index.html?agent-chat',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
@@ -1682,6 +1682,12 @@ for (const snippet of [
}
}
+if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
+ throw new Error(
+ 'AI game creator normal startup must not automatically open the developer window',
+ );
+}
+
const smokeAgentRunSource = fs.readFileSync(
new URL('./smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
index 7cd45583a..4289684ec 100644
--- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
+++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
@@ -43,7 +43,6 @@ export const deterministicManifestReadyAgentIds = Object.freeze([
const deterministicReadOnlyReadyAgentIds = new Set([
'design-director',
'balance-director',
- 'art-director',
'art-polish',
'audio-director',
'code-director',
@@ -82,7 +81,11 @@ function pngChunk(type, data) {
return Buffer.concat([length, typeBytes, data, crc]);
}
-function deterministicPng(width, height) {
+function deterministicPng(
+ width,
+ height,
+ { variant = 0, transparent = false } = {},
+) {
const stride = width * 4 + 1;
const pixels = Buffer.alloc(stride * height);
for (let y = 0; y < height; y += 1) {
@@ -91,10 +94,11 @@ function deterministicPng(width, height) {
for (let x = 0; x < width; x += 1) {
const offset = row + 1 + x * 4;
const tile = (Math.floor(x / 48) + Math.floor(y / 48)) % 6;
- pixels[offset] = (42 + x + tile * 29) % 256;
- pixels[offset + 1] = (86 + y * 2 + tile * 17) % 256;
- pixels[offset + 2] = (118 + x + y + tile * 31) % 256;
- pixels[offset + 3] = 255;
+ pixels[offset] = (42 + x + tile * 29 + variant * 13) % 256;
+ pixels[offset + 1] = (86 + y * 2 + tile * 17 + variant * 19) % 256;
+ pixels[offset + 2] = (118 + x + y + tile * 31 + variant * 23) % 256;
+ pixels[offset + 3] =
+ transparent && (x + y + variant) % 11 === 0 ? 0 : 255;
}
}
const ihdr = Buffer.alloc(13);
@@ -120,7 +124,7 @@ export function deterministicLaneDefenseInitialHtml() {
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}}
-
+
灵露花园 GENARRATIVE_REAL_E2E_VISIBLE
Goal: defend the garden and win every wave.
Start Game Speed Up Next Level Restart
露华花 棘刺芽
@@ -128,6 +132,7 @@ export function deterministicLaneDefenseInitialHtml() {
`;
}
@@ -374,12 +379,12 @@ function isUiPrototypeInspectionRequest(payload, context) {
: false;
return (
hasInputImage &&
- context.includes('resourceBar') &&
- context.includes('unitCardTray') &&
- context.includes('battlefieldGrid') &&
- context.includes('enemyEntryDirection') &&
- context.includes('waveStatus') &&
+ context.includes('informationHud') &&
+ context.includes('gameplaySurface') &&
+ context.includes('objectiveEntities') &&
context.includes('primaryControls') &&
+ context.includes('failureRestartFlow') &&
+ context.includes('responsiveLayout') &&
context.includes('implementationClarity') &&
context.includes('originalTheme')
);
@@ -397,6 +402,152 @@ function nativeResponse(response) {
return { name: 'respond_to_user', arguments: { response } };
}
+function goalContractCall() {
+ return nativeAction(
+ 'agent.goal_contract',
+ '冻结本轮可玩游戏交付目标与验收标准',
+ {
+ outcome:
+ '交付一个可完整游玩的原创植物塔防网页游戏,并让当前最终 revision 通过自动静态检查和桌面、移动双视口真实试玩。',
+ nonNegotiables: [
+ '必须形成从开始、放置防御单位、敌人移动与受伤到胜利、下一关和重开的完整闭环',
+ '必须在无人确认、无人补充输入和无人 steer 的条件下完成',
+ ],
+ preferences: ['保持单页自包含并提供清晰的桌面与移动端操作反馈'],
+ forbiddenAssumptions: [
+ '不得把静态页面、占位素材或未执行的试玩声明为完成',
+ ],
+ openQuestions: [],
+ acceptanceNodes: [
+ {
+ criterionId: 'static-current-revision',
+ criterion:
+ '当前最终 revision 的游戏入口通过可执行 JavaScript、Canvas 与完整玩法合同静态检查',
+ required: true,
+ requiredEvidence: ['tool:command.run_limited'],
+ dependsOn: [],
+ },
+ {
+ criterionId: 'playable-current-revision',
+ criterion:
+ '当前最终 revision 的完整塔防循环在桌面与移动视口均通过真实浏览器试玩',
+ required: true,
+ requiredEvidence: ['tool:preview.validate'],
+ dependsOn: ['static-current-revision'],
+ },
+ ],
+ },
+ );
+}
+
+function actionHistoryCall() {
+ return nativeAction(
+ 'agent.action_history',
+ '读取当前根 Run 的最终静态检查与真实试玩动作回执',
+ {
+ runId: null,
+ actionId: null,
+ tool: null,
+ status: 'ok',
+ limit: 10,
+ },
+ );
+}
+
+function goalContractFingerprint(context) {
+ const marker = '[Root Goal Contract]';
+ const start = context.lastIndexOf(marker);
+ if (start < 0) return null;
+ const tail = context.slice(start);
+ const end = tail.indexOf('\n\n');
+ const goalContractContext = end < 0 ? tail : tail.slice(0, end);
+ const matches = new Set(
+ [
+ ...goalContractContext.matchAll(
+ /"contractFingerprint"\s*:\s*"([0-9a-f]{64})"/giu,
+ ),
+ ].map((match) => match[1].toLowerCase()),
+ );
+ return matches.size === 1 ? [...matches][0] : null;
+}
+
+function finalAcceptanceEvidence(context, expectedRunId) {
+ const observation = latestToolObservation(context);
+ if (
+ observation?.tool !== 'agent.action_history' ||
+ observation?.status !== 'ok' ||
+ typeof observation.detail !== 'string'
+ ) {
+ throw providerError('provider-final-action-history-observation-invalid');
+ }
+ let detail;
+ try {
+ detail = JSON.parse(observation.detail);
+ } catch {
+ throw providerError('provider-final-action-history-detail-invalid');
+ }
+ if (
+ detail?.runId !== expectedRunId ||
+ !Array.isArray(detail?.actions) ||
+ detail?.count !== detail.actions.length
+ ) {
+ throw providerError('provider-final-action-history-identity-invalid');
+ }
+ const evidenceByTool = new Map();
+ for (const action of detail.actions) {
+ if (
+ action?.agentId !== 'project-supervisor' ||
+ action?.runId !== expectedRunId ||
+ action?.status !== 'ok' ||
+ typeof action?.actionId !== 'string' ||
+ !/^action-[0-9a-f]{24}$/iu.test(action.actionId) ||
+ !['command.run_limited', 'preview.validate'].includes(action?.tool)
+ ) {
+ continue;
+ }
+ evidenceByTool.set(action.tool, {
+ agentId: action.agentId,
+ runId: action.runId,
+ actionId: action.actionId,
+ });
+ }
+ const staticSmoke = evidenceByTool.get('command.run_limited');
+ const preview = evidenceByTool.get('preview.validate');
+ if (!staticSmoke || !preview) {
+ throw providerError('provider-final-acceptance-evidence-missing');
+ }
+ return { staticSmoke, preview };
+}
+
+function acceptanceUpdateCall(context, runId) {
+ const contractFingerprint = goalContractFingerprint(context);
+ if (!contractFingerprint) {
+ throw providerError('provider-goal-contract-fingerprint-missing');
+ }
+ const evidence = finalAcceptanceEvidence(context, runId);
+ return nativeAction(
+ 'agent.acceptance_update',
+ '提交当前最终 revision 的静态检查与双视口试玩验收证据',
+ {
+ contractFingerprint,
+ evaluations: [
+ {
+ criterionId: 'static-current-revision',
+ status: 'passed',
+ evidence: [evidence.staticSmoke],
+ summary: '当前最终 revision 已通过 game.static_smoke。',
+ },
+ {
+ criterionId: 'playable-current-revision',
+ status: 'passed',
+ evidence: [evidence.preview],
+ summary: '当前最终 revision 已通过桌面与移动双视口真实试玩。',
+ },
+ ],
+ },
+ );
+}
+
function completedPlanCall(explanation, steps) {
return {
name: 'update_agent_plan',
@@ -463,17 +614,6 @@ function chatTextResponse(sequence, model, content) {
};
}
-function delegateCall(agentId, task, acceptanceCriteria, expectedArtifacts) {
- return nativeAction('agent.delegate', `把独立工作交给 ${agentId}`, {
- agentId,
- task,
- acceptanceCriteria,
- expectedArtifacts,
- repairOfDelegationId: null,
- runId: null,
- });
-}
-
const projectMutationFunctionNames = new Set(
[
'blackboard.write',
@@ -492,22 +632,6 @@ const projectMutationFunctionNames = new Set(
].map(runtimeFunction),
);
-function isGameIndexArtifactContract(expectedArtifacts) {
- return (
- Array.isArray(expectedArtifacts) &&
- expectedArtifacts.length === 1 &&
- expectedArtifacts[0] === 'game/index.html'
- );
-}
-
-function isExplicitReadOnlyQualityTask(task) {
- return (
- typeof task === 'string' &&
- task.includes('只读验收') &&
- task.includes('不要修改任何文件')
- );
-}
-
function staticSmokeCall(reason) {
return nativeAction('command.run_limited', reason, {
commandId: 'game.static_smoke',
@@ -540,15 +664,6 @@ function runStatusCall(reason) {
});
}
-function repairPatchCall(reason) {
- return nativeAction('file.patch', reason, {
- path: 'game/index.html',
- oldText: hiddenCanvasCss,
- newText: visibleCanvasCss,
- expectedReplacements: 1,
- });
-}
-
function fileReadCall(path, reason) {
return nativeAction('file.read', reason, {
path,
@@ -566,6 +681,18 @@ function assetListCall(reason) {
}
function canvasAssetCall(agentId) {
+ if (agentId === 'art-director') {
+ return nativeAction('canvas.asset_generate', '生成统一视觉规范图', {
+ prompt:
+ '原创明亮花园植物塔防游戏的方形图标规范图。统一展示露华花守卫、棘刺芽守卫、雾影兽入侵者、晶苔地块、灵露资源和主要 UI 图标的轮廓、配色、材质与尺寸关系;纯色分区背景,适合后续角色、场景和 UI 共用,不使用任何现有游戏角色、Logo 或受保护视觉语言。',
+ outputPath: 'assets/art-spec.png',
+ aspectRatio: '1:1',
+ imageSize: '1K',
+ assetKind: 'icon-spec',
+ assetLabel: '游戏统一视觉规范图',
+ replaceExisting: false,
+ });
+ }
if (agentId === 'design-foundation') {
return nativeAction('canvas.asset_generate', '生成横屏游戏界面原型', {
prompt:
@@ -635,6 +762,67 @@ function revisionBlockedObservation(context) {
);
}
+function projectRevisionDriftObservation(context, tool) {
+ const observation = latestToolObservation(context);
+ const detail =
+ typeof observation?.detail === 'string' ? observation.detail : '';
+ const hasStructuredDetail =
+ detail.includes('projectRevisionDrift=true') &&
+ detail.includes('expectedRevision=') &&
+ detail.includes('currentRevision=') &&
+ detail.includes('replanRequired=true');
+ const detailUnavailable =
+ observation?.detail == null || detail === '[redacted sensitive context]';
+ return (
+ observation?.tool === tool &&
+ observation?.status === 'blocked' &&
+ observation?.summary === '并行项目变更使旧动作过期,旧动作未执行' &&
+ (hasStructuredDetail || detailUnavailable)
+ );
+}
+
+function visualAssetBlockedObservation(context) {
+ const observation = latestToolObservation(context);
+ return (
+ observation?.tool === 'runtime.visual_asset' &&
+ observation?.status === 'blocked' &&
+ typeof observation?.detail === 'string' &&
+ observation.detail.includes('expectedPath=assets/ui-prototype.png') &&
+ observation.detail.includes('requiredInspection=image.inspect')
+ );
+}
+
+function missingGeneratedVisualAssetObservation(context, agentId) {
+ const expected = {
+ 'art-director': {
+ path: 'assets/art-spec.png',
+ kind: 'icon-spec',
+ summary: '统一视觉规范图尚未按正式视觉流程生成并登记,不能完成任务',
+ },
+ 'design-foundation': {
+ path: 'assets/ui-prototype.png',
+ kind: 'ui-prototype',
+ summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
+ },
+ 'art-asset-plan': {
+ path: 'assets/art-spritesheet.png',
+ kind: 'art-spritesheet',
+ summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
+ },
+ }[agentId];
+ if (!expected) return false;
+ const observation = latestToolObservation(context);
+ return (
+ observation?.tool === 'runtime.visual_asset' &&
+ observation?.status === 'blocked' &&
+ observation?.summary === expected.summary &&
+ typeof observation?.detail === 'string' &&
+ (observation.detail === '[redacted sensitive context]' ||
+ (observation.detail.includes(`expectedPath=${expected.path}`) &&
+ observation.detail.includes(`expectedKind=${expected.kind}`)))
+ );
+}
+
function specialistVerificationRepairContext(context) {
return (
context.includes(
@@ -646,17 +834,27 @@ function specialistVerificationRepairContext(context) {
);
}
+function verifiedDeliveryRepairContext(context) {
+ return (
+ context.includes('当前 revision 已通过验证') &&
+ context.includes('本次修复的原生工具目录只保留 respond_to_user')
+ );
+}
+
function transientCommandObservation(context) {
const observation = latestToolObservation(context);
- const detail = observation?.detail;
+ const detail =
+ typeof observation?.detail === 'string'
+ ? observation.detail.replaceAll('\\', '/')
+ : '';
return (
observation?.tool === 'command.run_limited' &&
((observation?.status === 'blocked' &&
- (detail?.includes('repositoryContextDrift=true') ||
- (detail?.includes('projectRevisionDrift=true') &&
+ (detail.includes('repositoryContextDrift=true') ||
+ (detail.includes('projectRevisionDrift=true') &&
detail.includes('replanRequired=true')))) ||
(observation?.status === 'failed' &&
- detail?.includes('.agent/project.lock')))
+ detail.includes('.agent/project.lock')))
);
}
@@ -668,13 +866,6 @@ function successfulProjectMutationObservation(context) {
);
}
-function successfulCommandObservation(context) {
- const observation = latestToolObservation(context);
- return (
- observation?.tool === 'command.run_limited' && observation?.status === 'ok'
- );
-}
-
function observationsIncludeAsset(context, path) {
const observations = observationContext(context);
return observations.includes('# 本地项目资产') && observations.includes(path);
@@ -722,36 +913,20 @@ export function createDeterministicLaneDefenseRouter({
const readyTaskCompletionRetryCounts = new Map();
const readyTaskPreCompletionRetryCounts = new Map();
const readyTaskReplayAvailableRuns = new Set();
- const terminalRetryCounts = new Map();
- const terminalVerificationPendingRuns = new Set();
- const terminalReplayAvailableRuns = new Set();
- const terminalObservationFingerprints = new Map();
const agentCounts = new Map();
const stats = {
requestCount: 0,
contextCompactionRequestCount: 0,
planningRequestCount: 0,
finalReplyRequestCount: 0,
- initialDelegationCount: 0,
- followupDelegationCount: 0,
+ goalContractCount: 0,
+ actionHistoryCount: 0,
+ acceptanceUpdateCount: 0,
+ delegateActionCount: 0,
runStatusCount: 0,
sourceWriteCount: 0,
- sourcePatchCount: 0,
staticSmokeCount: 0,
previewValidationCount: 0,
- supervisorDirectMutationAttemptCount: 0,
- delegationContractCount: 0,
- delegationContractViolationCount: 0,
- codePrototypeDelegationCount: 0,
- codePrototypeExpectedArtifactCount: 0,
- codePrototypeGameIndexArtifactDelegationCount: 0,
- qualityReviewDelegationCount: 0,
- qualityReviewReadOnlyDelegationCount: 0,
- qualityReviewExpectedArtifactCount: 0,
- artAssetPlanDelegationCount: 0,
- artAssetPlanExpectedArtifactCount: 0,
- artAssetPlanArtifactContractCount: 0,
- qualityRevisionReplanCount: 0,
manifestReadyTaskRunCount: 0,
manifestReadyTaskCompletionCount: 0,
manifestReadyTaskFileReadCount: 0,
@@ -760,6 +935,7 @@ export function createDeterministicLaneDefenseRouter({
manifestReadyTaskPreviewValidationCount: 0,
manifestReadyTaskCanvasGenerationCount: 0,
canonicalCodeRunCount: 0,
+ canonicalCodeAssetListCount: 0,
imageInspectionRequestCount: 0,
interactionExecuteCount: 0,
readyTaskRunsByAgent: {},
@@ -771,9 +947,7 @@ export function createDeterministicLaneDefenseRouter({
byAgent: {},
};
let responseSequence = 0;
- let parentStage = 'initial-delegation';
- let initialBuilderRunId = null;
- let repairBuilderRunId = null;
+ let parentStage = 'goal-contract';
function updateReadyTaskCounts(agentId, kind) {
const current = stats.readyTaskCountsByAgent[agentId] ?? {
@@ -784,53 +958,6 @@ export function createDeterministicLaneDefenseRouter({
stats.readyTaskCountsByAgent[agentId] = current;
}
- function consumeTerminalRetry(agentId, runId) {
- const retryCount = terminalRetryCounts.get(runId) ?? 0;
- if (retryCount >= 16) {
- throw providerError(`provider-terminal-retry-exhausted:${agentId}`);
- }
- terminalRetryCounts.set(runId, retryCount + 1);
- }
-
- function retryTerminalStaticVerification(agentId, runId, tools, reason) {
- if (!tools.has(runtimeFunction('command.run_limited'))) {
- throw providerError(
- `provider-terminal-retry-verification-tool-missing:${agentId}`,
- );
- }
- consumeTerminalRetry(agentId, runId);
- terminalReplayAvailableRuns.delete(runId);
- terminalVerificationPendingRuns.add(runId);
- stats.staticSmokeCount += 1;
- return callsResponse(agentId, tools, [staticSmokeCall(reason)]);
- }
-
- function consumeTerminalReplayAuthorization(agentId, runId) {
- if (terminalVerificationPendingRuns.delete(runId)) return;
- if (terminalReplayAvailableRuns.delete(runId)) return;
- throw providerError(`provider-terminal-replay-unarmed:${agentId}`);
- }
-
- function consumeUniqueTerminalObservation(agentId, runId, context) {
- const observation = latestToolObservation(context);
- const repairFingerprint = context.includes('当前专业合同明确要求只读交付')
- ? 'read-only-delivery-repair'
- : null;
- if (!observation && !repairFingerprint) {
- throw providerError(`provider-terminal-observation-missing:${agentId}`);
- }
- const fingerprint = observation
- ? JSON.stringify(observation)
- : repairFingerprint;
- const seen = terminalObservationFingerprints.get(runId) ?? new Set();
- if (seen.has(fingerprint)) {
- throw providerError(`provider-terminal-observation-duplicate:${agentId}`);
- }
- consumeTerminalRetry(agentId, runId);
- seen.add(fingerprint);
- terminalObservationFingerprints.set(runId, seen);
- }
-
function recordReadyTaskRun(agentId, runId) {
if (!deterministicManifestReadyAgentIds.includes(agentId)) {
throw providerError(`provider-ready-agent-unsupported:${agentId}`);
@@ -893,6 +1020,21 @@ export function createDeterministicLaneDefenseRouter({
(agentId === 'preview-playtest'
? 'preview.validate'
: 'command.run_limited');
+ if (
+ tools.size === 1 &&
+ tools.has('respond_to_user') &&
+ verifiedDeliveryRepairContext(context)
+ ) {
+ readyTaskCompletionRetryArmedRuns.delete(runId);
+ readyTaskCompletionVerificationTools.delete(runId);
+ readyTaskReplayAvailableRuns.delete(runId);
+ return readyCallsResponse(
+ agentId,
+ runId,
+ tools,
+ readyTaskFinalizationCalls(context, agentId, tools),
+ );
+ }
if (readyTaskCompletionRetryArmedRuns.has(runId)) {
const observation = latestToolObservation(context);
if (
@@ -914,6 +1056,22 @@ export function createDeterministicLaneDefenseRouter({
readyTaskFinalizationCalls(context, agentId, tools),
);
}
+ if (
+ expectedVerificationTool === 'image.inspect' &&
+ observation?.tool === 'image.inspect' &&
+ ['blocked', 'failed'].includes(observation?.status) &&
+ tools.has(runtimeFunction('image.inspect'))
+ ) {
+ if (retryCount >= 16) {
+ throw providerError(
+ `provider-ready-retry-verification-exhausted:${agentId}`,
+ );
+ }
+ readyTaskCompletionRetryCounts.set(runId, retryCount + 1);
+ return readyCallsResponse(agentId, runId, tools, [
+ imageInspectCall(),
+ ]);
+ }
const transientCommandFailure = transientCommandObservation(context);
if (
transientCommandFailure &&
@@ -936,6 +1094,36 @@ export function createDeterministicLaneDefenseRouter({
);
}
const latestObservation = latestToolObservation(context);
+ if (
+ missingGeneratedVisualAssetObservation(context, agentId) &&
+ tools.has(runtimeFunction('canvas.asset_generate'))
+ ) {
+ if (retryCount >= 16) {
+ throw providerError(
+ `provider-ready-retry-verification-exhausted:${agentId}`,
+ );
+ }
+ readyTaskCompletionRetryCounts.set(runId, retryCount + 1);
+ stats.manifestReadyTaskCanvasGenerationCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ canvasAssetCall(agentId),
+ ]);
+ }
+ if (
+ agentId === 'design-foundation' &&
+ visualAssetBlockedObservation(context) &&
+ tools.has(runtimeFunction('image.inspect'))
+ ) {
+ if (retryCount >= 16) {
+ throw providerError(
+ `provider-ready-retry-verification-exhausted:${agentId}`,
+ );
+ }
+ readyTaskCompletionRetryCounts.set(runId, retryCount + 1);
+ readyTaskCompletionRetryArmedRuns.add(runId);
+ readyTaskCompletionVerificationTools.set(runId, 'image.inspect');
+ return readyCallsResponse(agentId, runId, tools, [imageInspectCall()]);
+ }
const successfulProjectMutation =
successfulProjectMutationObservation(context);
const standaloneSuccessfulVerification =
@@ -1120,52 +1308,20 @@ export function createDeterministicLaneDefenseRouter({
publishAgentCounts();
for (const call of calls) {
- if (call.name !== runtimeFunction('agent.delegate')) continue;
- stats.delegationContractCount += 1;
- const input = call.arguments?.input;
- const hasExplicitExpectedArtifacts = Array.isArray(
- input?.expectedArtifacts,
- );
- const expectedArtifacts = hasExplicitExpectedArtifacts
- ? input.expectedArtifacts
- : [];
- if (input?.agentId === 'code-prototype') {
- stats.codePrototypeDelegationCount += 1;
- stats.codePrototypeExpectedArtifactCount += expectedArtifacts.length;
- if (isGameIndexArtifactContract(expectedArtifacts)) {
- stats.codePrototypeGameIndexArtifactDelegationCount += 1;
- } else {
- stats.delegationContractViolationCount += 1;
- }
- } else if (input?.agentId === 'quality-review') {
- stats.qualityReviewDelegationCount += 1;
- stats.qualityReviewExpectedArtifactCount += expectedArtifacts.length;
- if (
- hasExplicitExpectedArtifacts &&
- expectedArtifacts.length === 0 &&
- isExplicitReadOnlyQualityTask(input.task)
- ) {
- stats.qualityReviewReadOnlyDelegationCount += 1;
- } else {
- stats.delegationContractViolationCount += 1;
- }
- } else if (input?.agentId === 'art-asset-plan') {
- stats.artAssetPlanDelegationCount += 1;
- stats.artAssetPlanExpectedArtifactCount += expectedArtifacts.length;
- if (
- hasExplicitExpectedArtifacts &&
- expectedArtifacts.length === 2 &&
- expectedArtifacts.includes('assets/manifest.art.json') &&
- expectedArtifacts.includes('assets/art-spritesheet.png') &&
- typeof input?.task === 'string' &&
- input.task.includes('生成')
- ) {
- stats.artAssetPlanArtifactContractCount += 1;
- } else {
- stats.delegationContractViolationCount += 1;
- }
- } else {
- stats.delegationContractViolationCount += 1;
+ if (call.name === runtimeFunction('agent.goal_contract')) {
+ stats.goalContractCount += 1;
+ } else if (call.name === runtimeFunction('agent.action_history')) {
+ stats.actionHistoryCount += 1;
+ } else if (call.name === runtimeFunction('agent.acceptance_update')) {
+ stats.acceptanceUpdateCount += 1;
+ } else if (call.name === runtimeFunction('agent.delegate')) {
+ stats.delegateActionCount += 1;
+ }
+ if (
+ agentId === 'code-prototype' &&
+ call.name === runtimeFunction('asset.list')
+ ) {
+ stats.canonicalCodeAssetListCount += 1;
}
}
}
@@ -1178,177 +1334,134 @@ export function createDeterministicLaneDefenseRouter({
}
function parentCalls(context, tools) {
- const manifestDagWaitPrompt =
- context.includes('当前正式 manifest DAG 仍有专业 task 在运行') &&
- tools.has(runtimeFunction('task.list')) &&
- tools.has(runtimeFunction('agent.run_status'));
- if (manifestDagWaitPrompt) {
- parentStage = 'await-manifest';
- stats.runStatusCount += 1;
- return callsResponse('project-supervisor', tools, [
- nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}),
- runStatusCall('等待并读取并行专业任务的最新状态'),
- ]);
- }
- const delegateRepairPrompt =
- (context.includes('当前父 run 已进入只编排模式') &&
- context.includes('只保留 agent.delegate')) ||
- (parentStage === 'await-delegation-repair' &&
- tools.has(runtimeFunction('agent.delegate')));
- if (delegateRepairPrompt) {
- if (parentStage !== 'await-delegation-repair') {
- throw providerError('provider-repair-prompt-out-of-order');
+ const goalContractTool = runtimeFunction('agent.goal_contract');
+ if (tools.size === 1 && tools.has(goalContractTool)) {
+ if (parentStage !== 'goal-contract') {
+ throw providerError('provider-goal-contract-out-of-order');
}
- parentStage = 'verify-repair';
- stats.followupDelegationCount += 1;
- return callsResponse('project-supervisor', tools, [
- delegateCall(
- 'code-prototype',
- '修复最近一次真实浏览器验证证明的 canvas 不可见问题;保持现有 lane-defense-v1 交互、状态面、关卡推进和重开行为不变,并在修改后通过静态自检。',
- [
- 'game/index.html 的 canvas 在桌面与移动视口可见且有非空像素',
- 'lane-defense-v1 全部固定试玩断言继续通过',
- '修改后的当前 revision 通过 game.static_smoke',
- ],
- ['game/index.html'],
- ),
- ]);
+ parentStage = 'await-manifest';
+ return callsResponse('project-supervisor', tools, [goalContractCall()]);
+ }
+
+ const advertisedProjectMutation = [...tools].some((tool) =>
+ projectMutationFunctionNames.has(tool),
+ );
+ const advertisedSafeExecution = [
+ 'agent.goal_contract',
+ 'task.list',
+ 'agent.run_status',
+ 'command.run_limited',
+ 'preview.validate',
+ 'agent.action_history',
+ 'agent.acceptance_update',
+ ].some((tool) => tools.has(runtimeFunction(tool)));
+ if (advertisedProjectMutation && !advertisedSafeExecution) {
+ if (
+ tools.has('respond_to_user') &&
+ context.includes('当前父 run 已进入只编排模式')
+ ) {
+ return callsResponse('project-supervisor', tools, [
+ nativeResponse('专业任务尚未收敛,继续等待 Runtime 完成门复核。'),
+ ]);
+ }
+ throw providerError('provider-supervisor-direct-mutation-denied');
}
switch (parentStage) {
- case 'initial-delegation': {
- parentStage = 'claim-initial';
- stats.initialDelegationCount += 3;
- return callsResponse('project-supervisor', tools, [
- delegateCall(
- 'code-prototype',
- '生成一个紧凑但完整的植物塔防网页游戏:支持开始、选择植物、放置防御单位、敌人移动和受伤、胜利、下一关与重开;写入 game/index.html,并在修改后通过静态自检。',
- [
- 'game/index.html 提供完整 lane-defense-v1 状态面和六个可点击控件',
- '敌人会移动并受伤,关卡可胜利、进入下一关并重开',
- '修改后的当前 revision 通过 game.static_smoke',
- ],
- ['game/index.html'],
- ),
- delegateCall(
- 'quality-review',
- '执行只读验收:独立核对塔防交付合同是否覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回合同验收重点,不要修改任何文件,也不要读取或依赖并行 code-prototype 尚未完成的项目产物。',
- [
- '验收结论覆盖完整可玩循环和双视口',
- '只读返回合同重点且项目 mutation 为零',
- ],
- [],
- ),
- delegateCall(
- 'art-asset-plan',
- '生成原创首版核心美术素材并写入正式资产清单;必须调用 canvas.asset_generate 生成 assets/art-spritesheet.png,并写入可解析的 assets/manifest.art.json,不得用占位文件代替。',
- [
- 'assets/art-spritesheet.png 是可解码的原创 1:1 PNG',
- 'assets/manifest.art.json 可解析并登记该图片',
- '生成结果已通过资产列表核对',
- ],
- ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
- ),
- ]);
- }
- case 'claim-initial':
- parentStage = 'static-initial';
- stats.runStatusCount += 1;
- return callsResponse('project-supervisor', tools, [
- runStatusCall('认领两份首轮专业回执'),
- ]);
- case 'static-initial':
- parentStage = 'preview-initial';
- stats.staticSmokeCount += 1;
- return callsResponse('project-supervisor', tools, [
- staticSmokeCall('验证初版当前 revision'),
- ]);
- case 'preview-initial':
- parentStage = 'attempt-forbidden-repair';
- stats.previewValidationCount += 1;
- return callsResponse('project-supervisor', tools, [
- previewCall('真实试玩并检查桌面与移动视口'),
- ]);
- case 'attempt-forbidden-repair':
- if (observationContext(context).includes('浏览器验证已通过')) {
- parentStage = 'respond';
- return parentCalls(context, tools);
- }
- parentStage = 'await-delegation-repair';
- stats.supervisorDirectMutationAttemptCount += 1;
- return callsResponse('project-supervisor', tools, [
- repairPatchCall('根据浏览器失败诊断直接修复不可见 canvas'),
- ]);
- case 'verify-repair':
- parentStage = 'await-manifest';
- stats.runStatusCount += 1;
- return callsResponse('project-supervisor', tools, [
- runStatusCall('先认领返工后的专业交付,再验证最新 revision'),
- ]);
case 'await-manifest':
- if (!tools.has(runtimeFunction('task.list'))) {
- if (tools.has(runtimeFunction('command.run_limited'))) {
- stats.staticSmokeCount += 1;
- return callsResponse('project-supervisor', tools, [
- staticSmokeCall('刷新并行产物修改后的当前 revision 验证凭证'),
- ]);
- }
- if (tools.has(runtimeFunction('preview.validate'))) {
- parentStage = 'respond';
- stats.previewValidationCount += 1;
- return callsResponse('project-supervisor', tools, [
- previewCall('对最终 current revision 完成双视口交互试玩'),
- ]);
- }
- }
if (!manifestTasksCompleted(context)) {
+ if (
+ !tools.has(runtimeFunction('task.list')) ||
+ !tools.has(runtimeFunction('agent.run_status'))
+ ) {
+ throw providerError('provider-manifest-wait-tools-invalid');
+ }
stats.runStatusCount += 1;
return callsResponse('project-supervisor', tools, [
nativeAction('task.list', '检查正式产物任务图是否已经收敛', {}),
runStatusCall('等待并读取并行专业任务的最新状态'),
]);
}
- parentStage = 'verify-final';
+ parentStage = 'final-static-smoke';
return parentCalls(context, tools);
- case 'verify-final':
- parentStage = 'respond';
+ case 'final-static-smoke':
+ if (!tools.has(runtimeFunction('command.run_limited'))) {
+ throw providerError('provider-final-static-smoke-tool-missing');
+ }
+ parentStage = 'final-preview';
stats.staticSmokeCount += 1;
- stats.previewValidationCount += 1;
return callsResponse('project-supervisor', tools, [
staticSmokeCall('验证全部正式产物整合后的当前 revision'),
+ ]);
+ case 'final-preview': {
+ const observation = latestToolObservation(context);
+ if (
+ observation?.tool !== 'command.run_limited' ||
+ observation?.status !== 'ok'
+ ) {
+ throw providerError(
+ 'provider-final-static-smoke-observation-invalid',
+ );
+ }
+ if (!tools.has(runtimeFunction('preview.validate'))) {
+ throw providerError('provider-final-preview-tool-missing');
+ }
+ parentStage = 'final-action-history';
+ stats.previewValidationCount += 1;
+ return callsResponse('project-supervisor', tools, [
previewCall('对最终 current revision 重跑完整真实试玩和双视口检查'),
]);
- case 'respond':
+ }
+ case 'final-action-history': {
+ const observation = latestToolObservation(context);
+ if (
+ observation?.tool !== 'preview.validate' ||
+ observation?.status !== 'ok'
+ ) {
+ throw providerError('provider-final-preview-observation-invalid');
+ }
+ if (!tools.has(runtimeFunction('agent.action_history'))) {
+ throw providerError('provider-final-action-history-tool-missing');
+ }
+ parentStage = 'final-acceptance-update';
+ return callsResponse('project-supervisor', tools, [
+ actionHistoryCall(),
+ ]);
+ }
+ case 'final-acceptance-update':
+ if (!tools.has(runtimeFunction('agent.acceptance_update'))) {
+ throw providerError('provider-final-acceptance-update-tool-missing');
+ }
+ {
+ const call = acceptanceUpdateCall(
+ context,
+ extractIdentity(context).runId,
+ );
+ parentStage = 'final-respond';
+ return callsResponse('project-supervisor', tools, [call]);
+ }
+ case 'final-respond': {
+ const observation = latestToolObservation(context);
+ if (
+ observation?.tool !== 'agent.acceptance_update' ||
+ observation?.status !== 'ok'
+ ) {
+ throw providerError('provider-final-acceptance-observation-invalid');
+ }
+ if (!tools.has('respond_to_user')) {
+ throw providerError('provider-final-respond-tool-missing');
+ }
parentStage = 'done';
return callsResponse('project-supervisor', tools, [
- ...completedPlanCallsForContext(
- context,
- '可试玩项目和全部验证已经完成',
- ),
nativeResponse(
- '可试玩塔防版本已经完成,并通过静态检查、完整交互试玩以及桌面和移动视口验证。',
+ '可试玩塔防版本已经完成,并通过当前 revision 静态检查、完整交互试玩以及桌面和移动视口验证。',
),
]);
+ }
case 'done':
- if (tools.has(runtimeFunction('task.list'))) {
- parentStage = 'await-manifest';
- return parentCalls(context, tools);
- }
- if (
- tools.has(runtimeFunction('command.run_limited')) &&
- tools.has(runtimeFunction('preview.validate'))
- ) {
- parentStage = 'verify-final';
- return parentCalls(context, tools);
- }
- if (tools.has('respond_to_user')) {
- return callsResponse('project-supervisor', tools, [
- nativeResponse(
- '可试玩塔防版本已经完成,并通过正式产物检查、静态检查和双视口交互试玩。',
- ),
- ]);
- }
- throw providerError('provider-parent-done-tools-invalid');
+ throw providerError('provider-parent-terminal-duplicate');
+ case 'goal-contract':
+ throw providerError('provider-goal-contract-required-first');
default:
throw providerError(`provider-parent-stage-invalid:${parentStage}`);
}
@@ -1369,12 +1482,6 @@ export function createDeterministicLaneDefenseRouter({
fileReadCall('memory/project.md', '读取长期项目约束'),
],
],
- 'art-director': [
- [
- fileReadCall('game/game_design.md', '读取玩法规格以确定视觉方向'),
- assetListCall('核对当前已有美术资产'),
- ],
- ],
'art-polish': [
[
fileReadCall(
@@ -1428,6 +1535,43 @@ export function createDeterministicLaneDefenseRouter({
);
}
+ function artDirectorReadyCalls(runId, tools, context) {
+ const agentId = 'art-director';
+ const phase = ensureReadyTaskRun(runId, agentId);
+ if (phase === 0) {
+ return readyCallsResponse(agentId, runId, tools, [
+ assetListCall('核对当前已有美术资产'),
+ ]);
+ }
+ if (phase === 1) {
+ if (!tools.has(runtimeFunction('canvas.asset_generate'))) {
+ throw providerError('provider-ready-art-director-canvas-tool-missing');
+ }
+ stats.manifestReadyTaskCanvasGenerationCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ canvasAssetCall(agentId),
+ ]);
+ }
+ if (phase === 2) {
+ if (
+ tools.has('respond_to_user') &&
+ !tools.has(runtimeFunction('asset.list'))
+ ) {
+ return readyTaskCompleteResponse(agentId, runId, tools, context);
+ }
+ return readyCallsResponse(agentId, runId, tools, [
+ assetListCall('核对统一视觉规范图已经生成并登记'),
+ ]);
+ }
+ if (phase === 3) {
+ return readyTaskCompleteResponse(agentId, runId, tools, context);
+ }
+ if (phase >= 4) {
+ return readyTaskCompleteResponse(agentId, runId, tools, context);
+ }
+ throw providerError(`provider-ready-art-director-stage-invalid:${phase}`);
+ }
+
function designFoundationReadyCalls(runId, tools, context) {
const agentId = 'design-foundation';
const phase = ensureReadyTaskRun(runId, agentId);
@@ -1590,93 +1734,6 @@ export function createDeterministicLaneDefenseRouter({
);
}
- function initialArtDelegationCalls(runId, tools, context) {
- const agentId = 'art-asset-plan';
- const existingKind = runKinds.get(runId);
- if (!existingKind) {
- runKinds.set(runId, 'initial-art-delegation');
- runPhases.set(runId, 0);
- } else if (existingKind !== 'initial-art-delegation') {
- throw providerError(`provider-initial-art-run-kind-invalid:${runId}`);
- }
- const phase = runPhases.get(runId) ?? 0;
- const respond = (calls) => {
- runPhases.set(runId, phase + 1);
- return callsResponse(agentId, tools, calls);
- };
- if (phase === 0) {
- return respond([assetListCall('核对首版核心美术是否已经生成')]);
- }
- if (phase === 1) {
- stats.sourceWriteCount += 1;
- return respond([
- fileWriteCall(
- 'assets/manifest.art.json',
- `${JSON.stringify(deterministicArtManifest(true), null, 2)}\n`,
- '写入登记首版核心图片的正式美术资产清单',
- ),
- ]);
- }
- if (phase === 2) {
- if (!tools.has(runtimeFunction('canvas.asset_generate'))) {
- throw providerError('provider-initial-art-canvas-tool-missing');
- }
- return respond([canvasAssetCall(agentId)]);
- }
- if (phase === 3) {
- return respond([
- fileReadCall('assets/manifest.art.json', '回读并核对正式美术清单'),
- assetListCall('确认核心美术图片已生成并登记'),
- ]);
- }
- if (phase === 4) {
- terminalReplayAvailableRuns.add(runId);
- return respond([
- ...completedPlanCallsForContext(
- context,
- '首版核心美术和正式资产清单已经完成',
- ),
- nativeResponse('首版核心美术图片与正式资产清单已经生成并核对。'),
- ]);
- }
- if (successfulCommandObservation(context) && tools.has('respond_to_user')) {
- consumeTerminalReplayAuthorization(agentId, runId);
- return respond([
- ...completedPlanCallsForContext(
- context,
- '首版核心美术和正式资产清单已经完成并通过验证',
- ),
- nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'),
- ]);
- }
- if (
- specialistVerificationRepairContext(context) ||
- revisionBlockedObservation(context) ||
- transientCommandObservation(context)
- ) {
- return retryTerminalStaticVerification(
- agentId,
- runId,
- tools,
- '补充验证首版核心美术和正式资产清单的当前 revision',
- );
- }
- if (
- tools.has('respond_to_user') &&
- context.includes('当前 revision 已通过验证')
- ) {
- consumeTerminalReplayAuthorization(agentId, runId);
- return respond([
- ...completedPlanCallsForContext(
- context,
- '首版核心美术和正式资产清单已经完成并通过验证',
- ),
- nativeResponse('首版核心美术图片与正式资产清单已经生成并验证。'),
- ]);
- }
- throw providerError('provider-initial-art-terminal-duplicate');
- }
-
function artAssetReadyCalls(runId, tools, context) {
const agentId = 'art-asset-plan';
const phase = ensureReadyTaskRun(runId, agentId);
@@ -1693,21 +1750,16 @@ export function createDeterministicLaneDefenseRouter({
const hasCanvas = tools.has(runtimeFunction('canvas.asset_generate'));
const willGenerate = hasCanvas && !existing;
const hasVisualAsset = existing || willGenerate;
- const calls = [
+ stats.sourceWriteCount += 1;
+ stats.manifestReadyTaskFileWriteCount += 1;
+ runData.set(runId, { existing, hasCanvas, willGenerate });
+ return readyCallsResponse(agentId, runId, tools, [
fileWriteCall(
'assets/manifest.art.json',
`${JSON.stringify(deterministicArtManifest(hasVisualAsset, 'manifest-ready'), null, 2)}\n`,
'写入可解析的首版美术资产清单',
),
- ];
- stats.sourceWriteCount += 1;
- stats.manifestReadyTaskFileWriteCount += 1;
- if (willGenerate) {
- calls.push(canvasAssetCall(agentId));
- stats.manifestReadyTaskCanvasGenerationCount += 1;
- }
- runData.set(runId, { existing, hasCanvas, willGenerate });
- return readyCallsResponse(agentId, runId, tools, calls);
+ ]);
}
if (phase === 2) {
const data = runData.get(runId) ?? {
@@ -1715,11 +1767,17 @@ export function createDeterministicLaneDefenseRouter({
hasCanvas: false,
willGenerate: false,
};
+ if (data.willGenerate) {
+ stats.manifestReadyTaskCanvasGenerationCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ canvasAssetCall(agentId),
+ ]);
+ }
const calls = [
fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'),
];
stats.manifestReadyTaskFileReadCount += 1;
- if (data.willGenerate) {
+ if (data.existing) {
calls.push(assetListCall('核对新图片已登记到项目资产清单'));
} else {
calls.push(staticSmokeCall('验证美术清单写入后的当前 revision'));
@@ -1728,11 +1786,9 @@ export function createDeterministicLaneDefenseRouter({
}
return readyCallsResponse(agentId, runId, tools, calls);
}
- if (phase === 3) {
- return readyTaskCompleteResponse(agentId, runId, tools, context);
- }
- if (phase >= 4) {
+ if (phase >= 3) {
if (
+ phase >= 5 &&
!tools.has('respond_to_user') &&
!tools.has(runtimeFunction('command.run_limited')) &&
tools.has(runtimeFunction('file.write'))
@@ -1747,6 +1803,51 @@ export function createDeterministicLaneDefenseRouter({
),
]);
}
+ if (completedReadyTaskRuns.has(runId)) {
+ return readyTaskCompleteResponse(agentId, runId, tools, context);
+ }
+ const data = runData.get(runId) ?? {
+ existing: false,
+ hasCanvas: false,
+ willGenerate: false,
+ };
+ if (
+ data.willGenerate &&
+ projectRevisionDriftObservation(context, 'canvas.asset_generate')
+ ) {
+ if (!tools.has(runtimeFunction('canvas.asset_generate'))) {
+ throw providerError('provider-ready-art-canvas-tool-missing');
+ }
+ const retryCount = readyTaskPreCompletionRetryCounts.get(runId) ?? 0;
+ if (retryCount >= 16) {
+ throw providerError('provider-ready-art-canvas-retry-exhausted');
+ }
+ readyTaskPreCompletionRetryCounts.set(runId, retryCount + 1);
+ stats.manifestReadyTaskCanvasGenerationCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ canvasAssetCall(agentId),
+ ]);
+ }
+ if (data.willGenerate) {
+ const observation = latestToolObservation(context);
+ if (
+ observation?.tool === 'canvas.asset_generate' &&
+ observation?.status === 'ok'
+ ) {
+ stats.manifestReadyTaskFileReadCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'),
+ assetListCall('核对新图片已登记到项目资产清单'),
+ ]);
+ }
+ if (phase === 3) {
+ stats.manifestReadyTaskFileReadCount += 1;
+ return readyCallsResponse(agentId, runId, tools, [
+ fileReadCall('assets/manifest.art.json', '回读并核对美术清单 JSON'),
+ assetListCall('核对新图片已登记到项目资产清单'),
+ ]);
+ }
+ }
return readyTaskCompleteResponse(agentId, runId, tools, context);
}
throw providerError(`provider-ready-art-stage-invalid:${phase}`);
@@ -1770,6 +1871,7 @@ export function createDeterministicLaneDefenseRouter({
fileReadCall('assets/manifest.art.json', '读取美术资产清单'),
fileReadCall('assets/manifest.audio.json', '读取声音事件清单'),
fileReadCall('game/index.html', '读取当前入口以保留既有试玩行为'),
+ assetListCall('核对 Canvas 登记、图集与四类切片的权威资产身份'),
]);
}
if (phase === 2) {
@@ -1859,7 +1961,6 @@ export function createDeterministicLaneDefenseRouter({
[
'design-director',
'balance-director',
- 'art-director',
'art-polish',
'audio-director',
'code-director',
@@ -1868,6 +1969,9 @@ export function createDeterministicLaneDefenseRouter({
) {
return readOnlyReadyCalls(agentId, runId, tools, context);
}
+ if (agentId === 'art-director') {
+ return artDirectorReadyCalls(runId, tools, context);
+ }
if (agentId === 'design-foundation') {
return designFoundationReadyCalls(runId, tools, context);
}
@@ -1907,223 +2011,6 @@ export function createDeterministicLaneDefenseRouter({
throw providerError(`provider-ready-agent-unsupported:${agentId}`);
}
- function codeCalls(runId, tools, context) {
- if (!runKinds.has(runId)) {
- if (!initialBuilderRunId) {
- initialBuilderRunId = runId;
- runKinds.set(runId, 'initial-builder');
- } else if (!repairBuilderRunId) {
- repairBuilderRunId = runId;
- runKinds.set(runId, 'repair-builder');
- } else {
- throw providerError('provider-extra-builder-run');
- }
- runPhases.set(runId, 0);
- }
- const kind = runKinds.get(runId);
- const phase = runPhases.get(runId);
- if (kind === 'initial-builder') {
- if (phase === 0) {
- runPhases.set(runId, 1);
- stats.sourceWriteCount += 1;
- return callsResponse('code-prototype', tools, [
- nativeAction('file.write', '写入完整可玩的塔防初版', {
- path: 'game/index.html',
- content: deterministicLaneDefenseInitialHtml(),
- }),
- ]);
- }
- if (phase === 1) {
- runPhases.set(runId, 2);
- stats.staticSmokeCount += 1;
- return callsResponse('code-prototype', tools, [
- staticSmokeCall('验证初版源码与状态面'),
- ]);
- }
- if (phase === 2) {
- runPhases.set(runId, 3);
- terminalReplayAvailableRuns.add(runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '初版实现和静态验证已经完成',
- ),
- nativeResponse('初版塔防玩法与交互状态面已经生成,并通过静态自检。'),
- ]);
- }
- if (phase >= 3) {
- if (
- successfulCommandObservation(context) &&
- tools.has('respond_to_user')
- ) {
- consumeTerminalReplayAuthorization('code-prototype', runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '初版实现和静态验证已经完成',
- ),
- nativeResponse(
- '初版塔防玩法与交互状态面已经生成,并通过静态自检。',
- ),
- ]);
- }
- if (
- specialistVerificationRepairContext(context) ||
- revisionBlockedObservation(context) ||
- transientCommandObservation(context)
- ) {
- return retryTerminalStaticVerification(
- 'code-prototype',
- runId,
- tools,
- '重试验证初版源码与状态面',
- );
- }
- if (
- tools.has('respond_to_user') &&
- context.includes('当前 revision 已通过验证')
- ) {
- consumeTerminalReplayAuthorization('code-prototype', runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '初版实现和静态验证已经完成',
- ),
- nativeResponse(
- '初版塔防玩法与交互状态面已经生成,并通过静态自检。',
- ),
- ]);
- }
- }
- }
- if (kind === 'repair-builder') {
- if (phase === 0) {
- runPhases.set(runId, 1);
- stats.sourcePatchCount += 1;
- return callsResponse('code-prototype', tools, [
- repairPatchCall('修复真实浏览器发现的 canvas 可见性'),
- ]);
- }
- if (phase === 1) {
- runPhases.set(runId, 2);
- stats.staticSmokeCount += 1;
- return callsResponse('code-prototype', tools, [
- staticSmokeCall('验证返工后的源码'),
- ]);
- }
- if (phase === 2) {
- runPhases.set(runId, 3);
- terminalReplayAvailableRuns.add(runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '画布可见性返工和静态验证已经完成',
- ),
- nativeResponse(
- '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。',
- ),
- ]);
- }
- if (phase >= 3) {
- if (
- successfulCommandObservation(context) &&
- tools.has('respond_to_user')
- ) {
- consumeTerminalReplayAuthorization('code-prototype', runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '画布可见性返工和静态验证已经完成',
- ),
- nativeResponse(
- '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。',
- ),
- ]);
- }
- if (
- specialistVerificationRepairContext(context) ||
- revisionBlockedObservation(context) ||
- transientCommandObservation(context)
- ) {
- return retryTerminalStaticVerification(
- 'code-prototype',
- runId,
- tools,
- '重试验证返工后的源码',
- );
- }
- if (
- tools.has('respond_to_user') &&
- context.includes('当前 revision 已通过验证')
- ) {
- consumeTerminalReplayAuthorization('code-prototype', runId);
- return callsResponse('code-prototype', tools, [
- ...completedPlanCallsForContext(
- context,
- '画布可见性返工和静态验证已经完成',
- ),
- nativeResponse(
- '画布可见性已修复,原有完整试玩行为保持不变并通过静态自检。',
- ),
- ]);
- }
- }
- }
- throw providerError(`provider-builder-stage-invalid:${kind}:${phase}`);
- }
-
- function qualityCalls(runId, tools, context) {
- const phase = runPhases.get(runId) ?? 0;
- if (phase === 0) {
- runKinds.set(runId, 'quality-review');
- runPhases.set(runId, 1);
- return callsResponse('quality-review', tools, [
- ...completedPlanCallsForContext(context, '独立验收重点已经核对'),
- nativeResponse(
- '验收必须以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。',
- ),
- ]);
- }
- if (phase === 1) {
- if (
- !revisionBlockedObservation(context) &&
- !context.includes('当前专业合同明确要求只读交付')
- ) {
- throw providerError('provider-quality-terminal-duplicate');
- }
- consumeUniqueTerminalObservation('quality-review', runId, context);
- runPhases.set(runId, 2);
- stats.qualityRevisionReplanCount += 1;
- return callsResponse('quality-review', tools, [
- ...completedPlanCallsForContext(
- context,
- '项目 revision 更新后已重新核对独立验收重点',
- ),
- nativeResponse(
- '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。',
- ),
- ]);
- }
- if (
- phase >= 2 &&
- tools.has('respond_to_user') &&
- (revisionBlockedObservation(context) ||
- context.includes('当前专业合同明确要求只读交付'))
- ) {
- consumeUniqueTerminalObservation('quality-review', runId, context);
- return callsResponse('quality-review', tools, [
- ...completedPlanCallsForContext(
- context,
- '项目 revision 更新后已重新核对独立验收重点',
- ),
- nativeResponse(
- '已基于最新项目 revision 复核验收重点,仍需以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。',
- ),
- ]);
- }
- throw providerError(`provider-quality-stage-invalid:${phase}`);
- }
-
function finalReply(identity, context) {
const key = `${identity.agentId}\0${identity.runId}`;
if (finalReplyRuns.has(key)) {
@@ -2135,6 +2022,9 @@ export function createDeterministicLaneDefenseRouter({
) {
throw providerError('provider-unexpected-text-request');
}
+ if (identity.agentId === 'project-supervisor' && parentStage !== 'done') {
+ throw providerError('provider-parent-final-reply-before-acceptance');
+ }
finalReplyRuns.add(key);
const content =
identity.agentId === 'project-supervisor'
@@ -2175,6 +2065,18 @@ export function createDeterministicLaneDefenseRouter({
context.includes('用户这轮输入:') &&
tools.has('runtime_execute')
) {
+ if (payload.reasoning_effort !== 'max') {
+ throw providerError('provider-interaction-reasoning-effort-invalid');
+ }
+ if (stats.interactionExecuteCount !== 0) {
+ throw providerError('provider-interaction-execute-duplicate');
+ }
+ if (
+ stats.manifestReadyTaskRunCount !== 0 ||
+ stats.manifestReadyTaskCompletionCount !== 0
+ ) {
+ throw providerError('provider-interaction-execute-out-of-order');
+ }
stats.planningRequestCount += 1;
stats.interactionExecuteCount += 1;
recordAgent('project-supervisor', 'planning');
@@ -2190,17 +2092,17 @@ export function createDeterministicLaneDefenseRouter({
model,
JSON.stringify({
checks: {
- resourceBar: true,
- unitCardTray: true,
- battlefieldGrid: true,
- enemyEntryDirection: true,
- waveStatus: true,
+ informationHud: true,
+ gameplaySurface: true,
+ objectiveEntities: true,
primaryControls: true,
+ failureRestartFlow: true,
+ responsiveLayout: true,
implementationClarity: true,
originalTheme: true,
},
issues: [],
- summary: '确定性 UI 原型覆盖完整横屏塔防实现合同。',
+ summary: '确定性 UI 原型覆盖完整玩法界面实现合同。',
}),
);
}
@@ -2253,15 +2155,6 @@ export function createDeterministicLaneDefenseRouter({
if (identity.agentId === 'project-supervisor') {
return parentCalls(context, tools);
}
- if (identity.agentId === 'code-prototype') {
- return codeCalls(identity.runId, tools, context);
- }
- if (identity.agentId === 'quality-review') {
- return qualityCalls(identity.runId, tools, context);
- }
- if (identity.agentId === 'art-asset-plan') {
- return initialArtDelegationCalls(identity.runId, tools, context);
- }
throw providerError(`provider-agent-unsupported:${identity.agentId}`);
} catch (error) {
stats.unexpectedRequestCount += 1;
@@ -2376,6 +2269,7 @@ function createDeterministicCanvasFixture(apiKey) {
canvasApiRequestCount: 0,
canvasGenerationRequestCount: 0,
canvasDownloadRequestCount: 0,
+ canvasSliceDownloadRequestCount: 0,
canvasGeneratedAspectRatios: {},
};
let projectTitle = null;
@@ -2473,6 +2367,137 @@ function createDeterministicCanvasFixture(apiKey) {
: '未命名游戏原型';
return json(200, { data: { folder: { folderId, label: folderLabel } } });
}
+ if (
+ request.method === 'POST' &&
+ parsed.pathname ===
+ '/api/external/v1/editor/icon-spritesheets/generations'
+ ) {
+ const idempotencyKey = request.headers['idempotency-key'];
+ if (
+ typeof idempotencyKey !== 'string' ||
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
+ idempotencyKey,
+ )
+ ) {
+ request.resume();
+ return json(400, { error: { message: 'invalid idempotency key' } });
+ }
+ const body = await readJsonBody(request);
+ const aspectRatio = body?.aspectRatio === '16:9' ? '16:9' : '1:1';
+ const width = aspectRatio === '16:9' ? 1280 : 1024;
+ const height = aspectRatio === '16:9' ? 720 : 1024;
+ generationSequence += 1;
+ stats.canvasGenerationRequestCount += 1;
+ stats.canvasGeneratedAspectRatios[aspectRatio] =
+ (stats.canvasGeneratedAspectRatios[aspectRatio] ?? 0) + 1;
+ const imageId = `deterministic-${generationSequence}`;
+ const objectKey = `generated/deterministic/${imageId}.png`;
+ const assetObjectId = `asset-object-${imageId}`;
+ const resourceId = `resource-${imageId}`;
+ const taskId = `task-${imageId}`;
+ images.set(imageId, {
+ aspectRatio,
+ width,
+ height,
+ bytes: deterministicPng(width, height, {
+ variant: generationSequence,
+ transparent: true,
+ }),
+ objectKey,
+ downloadKind: 'generation',
+ });
+ const sliceNames = ['玩家主体', '目标与危险物', '场景与资源', '反馈特效'];
+ const iconImageSrcs = sliceNames.map((name, index) => {
+ const sliceImageId = `${imageId}-slice-${index + 1}`;
+ const sliceObjectKey = `generated/deterministic/${sliceImageId}.png`;
+ const sliceResourceId = `resource-${sliceImageId}`;
+ const sliceAssetObjectId = `asset-object-${sliceImageId}`;
+ images.set(sliceImageId, {
+ aspectRatio: '1:1',
+ width: 256,
+ height: 256,
+ bytes: deterministicPng(256, 256, {
+ variant: generationSequence * 10 + index + 1,
+ }),
+ objectKey: sliceObjectKey,
+ downloadKind: 'slice',
+ });
+ return {
+ name,
+ imageSrc: `/${sliceObjectKey}`,
+ width: 256,
+ height: 256,
+ projectId,
+ taskId,
+ sourceResourceId: resourceId,
+ resource: {
+ resourceId: sliceResourceId,
+ projectId,
+ imageSrc: `/${sliceObjectKey}`,
+ objectKey: sliceObjectKey,
+ assetObjectId: sliceAssetObjectId,
+ taskId,
+ sourceResourceId: resourceId,
+ },
+ asset: {
+ assetId: `asset-${sliceImageId}`,
+ assetObjectId: sliceAssetObjectId,
+ assetKind: 'art-spritesheet-slice',
+ projectId,
+ taskId,
+ },
+ };
+ });
+ const operationId = `task-${imageId}`;
+ generationOperations.set(operationId, {
+ spritesheetImageSrc: `/${objectKey}`,
+ objectKey,
+ assetObjectId,
+ resourceId,
+ projectId,
+ taskId,
+ width,
+ height,
+ sourceType: 'generated',
+ prompt:
+ body?.iconDescriptions?.join('\n') ??
+ 'deterministic spritesheet fixture',
+ actualPrompt:
+ body?.iconDescriptions?.join('\n') ??
+ 'deterministic spritesheet fixture',
+ model: 'deterministic-canvas-v1',
+ provider: 'deterministic-loopback',
+ spritesheetResource: {
+ resourceId,
+ projectId,
+ imageSrc: `/${objectKey}`,
+ objectKey,
+ assetObjectId,
+ width,
+ height,
+ sourceType: 'generated',
+ taskId,
+ },
+ spritesheetAsset: {
+ assetId: `asset-${imageId}`,
+ assetObjectId,
+ assetKind: 'art-spritesheet',
+ projectId,
+ taskId,
+ },
+ iconImageSrcs,
+ });
+ return json(202, {
+ data: {
+ operationId,
+ kind: 'editor_icon_spritesheet_generation',
+ status: 'queued',
+ statusUrl: `/api/external/v1/generations/${operationId}`,
+ pollAfterMs: 1,
+ updatedAtMicros: generationSequence,
+ },
+ });
+ }
if (
request.method === 'POST' &&
parsed.pathname === '/api/external/v1/editor/images/generations'
@@ -2499,7 +2524,11 @@ function createDeterministicCanvasFixture(apiKey) {
const resourceId = `resource-${imageId}`;
const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
- images.set(imageId, { ...image, objectKey });
+ images.set(imageId, {
+ ...image,
+ objectKey,
+ downloadKind: 'generation',
+ });
const operationId = `task-${imageId}`;
generationOperations.set(operationId, {
imageSrc: `/${objectKey}`,
@@ -2555,7 +2584,8 @@ function createDeterministicCanvasFixture(apiKey) {
'/api/external/v1/generations/'.length,
);
const result = generationOperations.get(operationId);
- if (!result) return json(404, { error: { message: 'operation not found' } });
+ if (!result)
+ return json(404, { error: { message: 'operation not found' } });
return json(200, {
data: {
operationId,
@@ -2600,7 +2630,11 @@ function createDeterministicCanvasFixture(apiKey) {
const imageId = parsed.pathname.slice('/signed/'.length, -'.png'.length);
const image = images.get(imageId);
if (!image) return json(404, { error: { message: 'image not found' } });
- stats.canvasDownloadRequestCount += 1;
+ if (image.downloadKind === 'slice') {
+ stats.canvasSliceDownloadRequestCount += 1;
+ } else {
+ stats.canvasDownloadRequestCount += 1;
+ }
return {
statusCode: 200,
contentType: 'image/png',
diff --git a/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs
new file mode 100644
index 000000000..09269cdc6
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs
@@ -0,0 +1,139 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import readline from 'node:readline';
+
+const configPath =
+ process.env.AGC_CONFIG_PATH ??
+ 'C:/Users/kdletters/AppData/Roaming/world.genarrative.ai-game-creator/game-creator.config.json';
+const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+const args = [
+ 'app-server',
+ '--stdio',
+ '-c',
+ 'mcp_servers={}',
+ '-c',
+ 'web_search="disabled"',
+ '-c',
+ 'agents.enabled=false',
+];
+for (const feature of [
+ 'apps',
+ 'browser_use',
+ 'browser_use_external',
+ 'browser_use_full_cdp_access',
+ 'computer_use',
+ 'goals',
+ 'image_generation',
+ 'in_app_browser',
+ 'plugins',
+ 'remote_plugin',
+ 'shell_tool',
+ 'tool_suggest',
+ 'unified_exec',
+ 'workspace_dependencies',
+])
+ args.push('--disable', feature);
+args.push(
+ '-c',
+ 'model_provider="genarrative_agc"',
+ '-c',
+ 'model_providers.genarrative_agc.name="Genarrative AGC"',
+ '-c',
+ `model_providers.genarrative_agc.base_url="${config.llm.baseUrl.replace(/\/$/, '')}"`,
+ '-c',
+ 'model_providers.genarrative_agc.env_key="GENARRATIVE_AGC_API_KEY"',
+ '-c',
+ 'model_providers.genarrative_agc.wire_api="responses"',
+);
+const child = spawn(
+ 'C:/Program Files/nodejs/node.exe',
+ [
+ 'C:/Users/kdletters/AppData/Roaming/npm/node_modules/@openai/codex/bin/codex.js',
+ ...args,
+ ],
+ {
+ cwd: process.cwd(),
+ env: { ...process.env, GENARRATIVE_AGC_API_KEY: config.llm.apiKey },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ },
+);
+const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`);
+const timer = setTimeout(
+ () => {
+ console.log('smoke-timeout');
+ child.kill();
+ process.exit(1);
+ },
+ Number(process.env.DIRECT_SMOKE_TIMEOUT_MS ?? 180000),
+);
+readline.createInterface({ input: child.stdout }).on('line', (line) => {
+ let message;
+ try {
+ message = JSON.parse(line);
+ } catch {
+ return;
+ }
+ if (message.id === 2) {
+ console.log('thread-start-ok');
+ const write = process.env.DIRECT_SMOKE_WRITE === '1';
+ send({
+ id: 3,
+ method: 'turn/start',
+ params: {
+ threadId: message.result.thread.id,
+ input: [
+ {
+ type: 'text',
+ text:
+ process.env.DIRECT_SMOKE_PROMPT ??
+ (write
+ ? '在当前工作区创建 smoke-marker.txt,写入 DIRECT_CODEX_WRITE_OK,然后回复已完成。'
+ : '只回复 DIRECT_CODEX_SMOKE_OK,不要修改文件。'),
+ },
+ ],
+ model: config.llm.model,
+ approvalPolicy: 'never',
+ sandboxPolicy: write
+ ? {
+ type: 'workspaceWrite',
+ writableRoots: [process.cwd()],
+ networkAccess: true,
+ }
+ : { type: 'readOnly', networkAccess: false },
+ },
+ });
+ }
+ if (message.id === 3 && message.error) {
+ console.log(`turn-start-error:${message.error.message}`);
+ clearTimeout(timer);
+ child.kill();
+ process.exit(1);
+ }
+ if (message.method === 'turn/completed') {
+ console.log(`turn-completed:${message.params?.turn?.status}`);
+ clearTimeout(timer);
+ child.kill();
+ process.exit(message.params?.turn?.status === 'completed' ? 0 : 1);
+ }
+});
+send({
+ id: 1,
+ method: 'initialize',
+ params: {
+ clientInfo: { name: 'agc-direct-smoke', version: '0.1' },
+ capabilities: { experimentalApi: true },
+ },
+});
+send({ method: 'initialized', params: {} });
+send({
+ id: 2,
+ method: 'thread/start',
+ params: {
+ cwd: process.cwd(),
+ model: config.llm.model,
+ approvalPolicy: 'never',
+ sandbox:
+ process.env.DIRECT_SMOKE_WRITE === '1' ? 'workspace-write' : 'read-only',
+ ephemeral: true,
+ },
+});
diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
index 355e29197..db7c8d42c 100644
--- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
@@ -47,7 +47,7 @@ function isAiGameCreatorServer(response) {
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
- response.body.includes('AI 游戏创作 ') &&
+ response.body.includes('陶泥儿 ') &&
response.body.includes('/src/main.tsx')
);
}
diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
index 2bec7f09e..687587b18 100644
--- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
@@ -158,7 +158,7 @@ function isAiGameCreatorServer(response) {
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
- response.body.includes('AI 游戏创作 ') &&
+ response.body.includes('陶泥儿 ') &&
response.body.includes('/src/main.tsx')
);
}
diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
index 50d312f47..c8255bc4c 100644
--- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
+++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
@@ -11,7 +11,13 @@
"core:resources:allow-close",
{
"identifier": "http:default",
- "allow": [{ "url": "https://dev.genarrative.world/api/*" }]
+ "allow": [
+ { "url": "https://dev.genarrative.world/api/*" },
+ { "url": "https://www.genarrative.world/api/*" },
+ { "url": "https://*/api/*" },
+ { "url": "http://localhost:*/*" },
+ { "url": "http://127.0.0.1:*/*" }
+ ]
},
"opener:default"
]
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
index 8bda8dd61..8020721aa 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
@@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod codex_app_server;
mod codex_cli;
+mod direct_runtime;
mod generation;
mod interaction;
mod prompt;
@@ -20,11 +21,13 @@ mod runtime_driver;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
+pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
use codex_app_server::*;
use codex_cli::*;
pub(crate) use codex_cli::{
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
};
+pub(crate) use direct_runtime::*;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
index 29ff244fe..6be5feca1 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
@@ -1,4 +1,5 @@
use super::*;
+use platform_llm::LlmMessageRole;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::process::Stdio;
@@ -21,6 +22,8 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
"codex-app-server-error:";
+const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
+ "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
type RpcResult = Result;
@@ -317,6 +320,7 @@ struct CodexAppServerInner {
last_used: AtomicU64,
closed: AtomicBool,
_working_dir: tempfile::TempDir,
+ workspace_path: std::path::PathBuf,
}
#[derive(Clone)]
@@ -370,6 +374,27 @@ fn game_creator_codex_app_server_pool_key(
format!("{:x}", Sha256::digest(stable.to_string().as_bytes()))
}
+fn direct_codex_base_instructions(request: &LlmRunRequest) -> String {
+ request
+ .messages
+ .iter()
+ .find(|message| message.role == LlmMessageRole::System)
+ .map(|message| message.content.trim())
+ .filter(|value| !value.is_empty())
+ .unwrap_or(DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK)
+ .to_string()
+}
+
+fn direct_codex_user_prompt(request: &LlmRunRequest) -> String {
+ request
+ .messages
+ .iter()
+ .filter(|message| message.role != LlmMessageRole::System)
+ .map(|message| message.content.as_str())
+ .collect::>()
+ .join("\n\n")
+}
+
fn find_game_creator_codex_auth_path() -> Option {
std::env::var_os("CODEX_HOME")
.map(std::path::PathBuf::from)
@@ -450,6 +475,15 @@ fn game_creator_codex_app_server_validate_llm_config(
Ok(())
}
+fn apply_game_creator_codex_app_server_reasoning_effort(
+ params: &mut serde_json::Value,
+ request: &LlmRunRequest,
+) {
+ if let Some(effort) = game_creator_codex_cli_reasoning_effort(request) {
+ params["effort"] = serde_json::Value::String(effort.to_string());
+ }
+}
+
fn quoted_toml_string(value: &str) -> Result {
serde_json::to_string(value).map_err(|error| {
platform_llm::LlmError::InvalidConfig(format!("序列化 Codex app-server 配置失败:{error}"))
@@ -469,7 +503,7 @@ fn configure_game_creator_codex_app_server_command(
.arg("web_search=\"disabled\"")
.arg("-c")
.arg("agents.enabled=false");
- for feature in [
+ let disabled_features = [
"apps",
"browser_use",
"browser_use_external",
@@ -480,13 +514,18 @@ fn configure_game_creator_codex_app_server_command(
"in_app_browser",
"plugins",
"remote_plugin",
- "shell_tool",
"tool_suggest",
- "unified_exec",
"workspace_dependencies",
- ] {
+ ];
+ for feature in disabled_features {
command.arg("--disable").arg(feature);
}
+ // AGC does not implement an approval broker for shell execution. Keep the
+ // native file-change tool available for direct Codex turns, but disable
+ // shell/unified_exec so the model cannot fall back to command requests that
+ // the unattended direct bridge must reject.
+ command.arg("--disable").arg("shell_tool");
+ command.arg("--disable").arg("unified_exec");
if !llm.api_key.trim().is_empty() {
command
.arg("-c")
@@ -548,10 +587,32 @@ fn prepare_isolated_game_creator_codex_home(
Ok(isolated_home)
}
+fn trust_isolated_game_creator_codex_workspace(
+ codex_home: &std::path::Path,
+ workspace: &std::path::Path,
+) -> Result<(), platform_llm::LlmError> {
+ let workspace = workspace.to_string_lossy();
+ let quoted_workspace = quoted_toml_string(&workspace)?;
+ let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n");
+ std::fs::write(codex_home.join("config.toml"), config).map_err(|error| {
+ platform_llm::LlmError::Transport(format!(
+ "写入隔离 Codex app-server 项目信任配置失败:{error}"
+ ))
+ })
+}
+
impl CodexAppServerConnection {
async fn acquire(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
+ ) -> Result {
+ Self::acquire_at_workspace(snapshot, llm, None).await
+ }
+
+ async fn acquire_at_workspace(
+ snapshot: &AgentRuntimeProviderRequestSnapshot,
+ llm: &GameCreatorLlmConfig,
+ workspace_override: Option<&std::path::Path>,
) -> Result {
game_creator_codex_app_server_validate_llm_config(llm)?;
let codex_cli_version = game_creator_codex_cli_version_identity()
@@ -596,7 +657,20 @@ impl CodexAppServerConnection {
.await;
}
}
- let connection = Self::spawn(llm, &credential).await?;
+ let connection = match workspace_override {
+ Some(workspace) => {
+ let executable = game_creator_codex_cli_executable_path()
+ .map_err(platform_llm::LlmError::InvalidConfig)?;
+ Self::spawn_with_executable_and_credential_at_workspace(
+ llm,
+ &credential,
+ executable.as_os_str(),
+ Some(workspace),
+ )
+ .await?
+ }
+ None => Self::spawn(llm, &credential).await?,
+ };
pool.insert(key, Arc::clone(&connection.inner));
Ok(connection)
}
@@ -622,6 +696,16 @@ impl CodexAppServerConnection {
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
executable: &std::ffi::OsStr,
+ ) -> Result {
+ Self::spawn_with_executable_and_credential_at_workspace(llm, credential, executable, None)
+ .await
+ }
+
+ async fn spawn_with_executable_and_credential_at_workspace(
+ llm: &GameCreatorLlmConfig,
+ credential: &CodexAppServerCredential,
+ executable: &std::ffi::OsStr,
+ workspace_override: Option<&std::path::Path>,
) -> Result {
let working_dir = tempfile::Builder::new()
.prefix("genarrative-agc-codex-app-server-")
@@ -634,16 +718,24 @@ impl CodexAppServerConnection {
let isolated_codex_home =
prepare_isolated_game_creator_codex_home(working_dir.path(), credential)?;
let isolated_workspace = working_dir.path().join("workspace");
- std::fs::create_dir(&isolated_workspace).map_err(|error| {
- platform_llm::LlmError::Transport(format!(
- "创建 Codex app-server 隔离工作目录失败:{error}"
- ))
- })?;
- std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| {
- platform_llm::LlmError::Transport(format!(
- "创建 Codex app-server 隔离仓库边界失败:{error}"
- ))
- })?;
+ if workspace_override.is_none() {
+ std::fs::create_dir(&isolated_workspace).map_err(|error| {
+ platform_llm::LlmError::Transport(format!(
+ "创建 Codex app-server 隔离工作目录失败:{error}"
+ ))
+ })?;
+ std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| {
+ platform_llm::LlmError::Transport(format!(
+ "创建 Codex app-server 隔离仓库边界失败:{error}"
+ ))
+ })?;
+ }
+ let workspace_path = workspace_override
+ .map(std::path::Path::to_path_buf)
+ .unwrap_or_else(|| isolated_workspace.clone());
+ if workspace_override.is_some() {
+ trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?;
+ }
let isolated_os_home = working_dir.path().join("home");
let isolated_app_data = isolated_os_home.join("appdata");
let isolated_local_app_data = isolated_os_home.join("local-appdata");
@@ -661,7 +753,7 @@ impl CodexAppServerConnection {
let mut command = tokio::process::Command::new(executable);
configure_game_creator_codex_app_server_command(&mut command, llm)?;
command
- .current_dir(&isolated_workspace)
+ .current_dir(&workspace_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -705,6 +797,7 @@ impl CodexAppServerConnection {
last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()),
closed: AtomicBool::new(false),
_working_dir: working_dir,
+ workspace_path,
});
tokio::spawn(read_game_creator_codex_app_server_stdout(
Arc::downgrade(&inner),
@@ -868,13 +961,25 @@ impl CodexAppServerConnection {
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(&llm.model);
+ let direct_workspace =
+ self.inner.workspace_path != self.inner._working_dir.path().join("workspace");
+ let approval_policy = if direct_workspace {
+ "on-request"
+ } else {
+ "never"
+ };
+ let base_instructions = if direct_workspace {
+ direct_codex_base_instructions(&request)
+ } else {
+ DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string()
+ };
let mut params = serde_json::json!({
"model": model,
- "cwd": self.inner._working_dir.path().join("workspace"),
- "approvalPolicy": "never",
- "sandbox": "read-only",
+ "cwd": self.inner.workspace_path,
+ "approvalPolicy": approval_policy,
+ "sandbox": if direct_workspace { "workspace-write" } else { "read-only" },
"ephemeral": true,
- "baseInstructions": "You are an isolated reasoning engine for Genarrative AGC. Never use native tools, shell, files, network, MCP, skills, plugins, hooks, or sub-agents. Use only the complete message snapshot provided in each turn and return only the requested answer contract."
+ "baseInstructions": base_instructions
});
if !llm.api_key.trim().is_empty() {
params["modelProvider"] =
@@ -956,8 +1061,19 @@ impl CodexAppServerConnection {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
let thread_id = thread_lease.thread_id.clone();
- let prompt = render_game_creator_codex_cli_prompt(&request)
- .map_err(platform_llm::LlmError::InvalidRequest)?;
+ let direct_workspace =
+ self.inner.workspace_path != self.inner._working_dir.path().join("workspace");
+ let approval_policy = if direct_workspace {
+ "on-request"
+ } else {
+ "never"
+ };
+ let prompt = if direct_workspace {
+ direct_codex_user_prompt(&request)
+ } else {
+ render_game_creator_codex_cli_prompt(&request)
+ .map_err(platform_llm::LlmError::InvalidRequest)?
+ };
let model = request
.model
.as_deref()
@@ -967,12 +1083,14 @@ impl CodexAppServerConnection {
"threadId": thread_id,
"input": [{ "type": "text", "text": prompt }],
"model": model,
- "approvalPolicy": "never",
- "sandboxPolicy": { "type": "readOnly", "networkAccess": false },
+ "approvalPolicy": approval_policy,
+ "sandboxPolicy": {
+ "type": "workspaceWrite",
+ "writableRoots": [self.inner.workspace_path],
+ "networkAccess": true
+ },
});
- if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) {
- params["effort"] = serde_json::Value::String(effort.to_string());
- }
+ apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request);
if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) {
params["outputSchema"] = schema;
}
@@ -1054,10 +1172,12 @@ impl CodexAppServerConnection {
{
final_text = Some(text.to_string());
}
- } else if !matches!(
- item_type,
- "userMessage" | "plan" | "reasoning" | "contextCompaction"
- ) {
+ } else if !direct_workspace
+ && !matches!(
+ item_type,
+ "userMessage" | "plan" | "reasoning" | "contextCompaction"
+ )
+ {
return Err(platform_llm::LlmError::InvalidRequest(format!(
"Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}"
)));
@@ -1319,17 +1439,148 @@ async fn read_game_creator_codex_app_server_stdout(
let Some(inner) = inner.upgrade() else {
return;
};
+ let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some()
+ && inner.workspace_path != inner._working_dir.path().join("workspace");
+ if direct_debug {
+ let method = message
+ .get("method")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ if !method.is_empty() {
+ let params_keys = message
+ .get("params")
+ .and_then(serde_json::Value::as_object)
+ .map(|params| params.keys().cloned().collect::>())
+ .unwrap_or_default();
+ eprintln!(
+ "agent.direct_codex.event method={} paramsKeys={:?}",
+ method, params_keys
+ );
+ } else if let Some(params) = message.get("params") {
+ let event_name = params
+ .get("type")
+ .and_then(serde_json::Value::as_str)
+ .or_else(|| {
+ params
+ .get("item")
+ .and_then(|item| item.get("type"))
+ .and_then(serde_json::Value::as_str)
+ })
+ .unwrap_or("unknown");
+ let status = params
+ .pointer("/turn/status")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or("");
+ eprintln!(
+ "agent.direct_codex.event notification={} status={}",
+ event_name, status
+ );
+ if event_name == "item/completed" || event_name == "item/started" {
+ if let Some(item) = params.get("item") {
+ let item_type = item
+ .get("type")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or("unknown");
+ let command = item
+ .get("command")
+ .and_then(serde_json::Value::as_str)
+ .map(|value| value.chars().take(512).collect::());
+ let change_paths = item
+ .get("changes")
+ .and_then(serde_json::Value::as_array)
+ .map(|changes| {
+ changes
+ .iter()
+ .filter_map(|change| {
+ change
+ .get("path")
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string)
+ })
+ .collect::>()
+ });
+ eprintln!(
+ "agent.direct_codex.item event={} type={} command={:?} changePaths={:?}",
+ event_name,
+ item_type,
+ command,
+ change_paths
+ );
+ }
+ }
+ }
+ }
if let Some(id) = message.get("id").and_then(serde_json::Value::as_u64) {
if message.get("method").is_some() {
let writer = Arc::clone(&inner);
tokio::spawn(async move {
- let response = serde_json::json!({
- "id": id,
- "error": {
- "code": -32601,
- "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
+ let direct_workspace =
+ inner.workspace_path != inner._working_dir.path().join("workspace");
+ let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some();
+ let method = message
+ .get("method")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ let requested_grant_root = message
+ .get("params")
+ .and_then(|params| params.get("grantRoot"))
+ .and_then(serde_json::Value::as_str);
+ if direct_workspace && direct_debug {
+ eprintln!(
+ "agent.direct_codex.server_request method={method} grantRoot={requested_grant_root:?}"
+ );
+ }
+ let file_change_within_workspace =
+ game_creator_codex_file_change_request_is_allowed(
+ &inner.workspace_path,
+ method,
+ requested_grant_root,
+ );
+ let response = if direct_workspace
+ && file_change_within_workspace
+ && matches!(
+ method,
+ "item/commandExecution/requestApproval"
+ | "item/fileChange/requestApproval"
+ | "item/mcpToolCall/requestApproval"
+ | "item/permissions/requestApproval"
+ ) {
+ if method == "item/permissions/requestApproval" {
+ serde_json::json!({
+ "id": id,
+ "result": {
+ "permissions": {
+ "fileSystem": {
+ "write": [inner.workspace_path]
+ },
+ "network": { "enabled": true }
+ },
+ "scope": "turn"
+ }
+ })
+ } else {
+ serde_json::json!({
+ "id": id,
+ "result": { "decision": "accept" }
+ })
}
- });
+ } else if direct_workspace && method == "item/fileChange/requestApproval" {
+ serde_json::json!({
+ "id": id,
+ "error": {
+ "code": -32602,
+ "message": "Genarrative AGC 只允许当前项目工作区的文件变更审批"
+ }
+ })
+ } else {
+ serde_json::json!({
+ "id": id,
+ "error": {
+ "code": -32601,
+ "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
+ }
+ })
+ };
let mut bytes = match serde_json::to_vec(&response) {
Ok(bytes) => bytes,
Err(_) => return,
@@ -1488,6 +1739,17 @@ async fn read_game_creator_codex_app_server_stderr(
let Some(upgraded) = inner.upgrade() else {
return;
};
+ if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some()
+ && upgraded.workspace_path != upgraded._working_dir.path().join("workspace")
+ {
+ let text = String::from_utf8_lossy(&buffer[..count]);
+ let bounded = text.chars().take(2_048).collect::();
+ eprintln!(
+ "agent.direct_codex.stderr bytes={} text={}",
+ count,
+ redact_game_creator_codex_debug_text(&bounded)
+ );
+ }
let oversized_record = upgraded
.stderr_summary
.lock()
@@ -1505,6 +1767,99 @@ async fn read_game_creator_codex_app_server_stderr(
}
}
+fn redact_game_creator_codex_debug_text(text: &str) -> String {
+ let mut redacted = text.to_string();
+ let mut bearer_search_from = 0;
+ while let Some(relative_start) = redacted[bearer_search_from..].find("Bearer ") {
+ let start = bearer_search_from + relative_start;
+ let value_start = start + "Bearer ".len();
+ let value_end = redacted[value_start..]
+ .find(char::is_whitespace)
+ .map(|offset| value_start + offset)
+ .unwrap_or(redacted.len());
+ redacted.replace_range(value_start..value_end, "[redacted]");
+ bearer_search_from = value_start + "[redacted]".len();
+ }
+ for marker in ["apiKey=", "api_key=", "token=", "access_token="] {
+ let mut search_from = 0;
+ while let Some(relative_start) = redacted[search_from..].find(marker) {
+ let start = search_from + relative_start;
+ let value_start = start + marker.len();
+ let value_end = redacted[value_start..]
+ .find(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '}' | '"'))
+ .map(|offset| value_start + offset)
+ .unwrap_or(redacted.len());
+ redacted.replace_range(value_start..value_end, "[redacted]");
+ search_from = value_start + "[redacted]".len();
+ }
+ }
+ redacted
+}
+
+fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String {
+ let value = path.to_string_lossy();
+ value
+ .strip_prefix("\\\\?\\")
+ .unwrap_or(value.as_ref())
+ .replace('/', "\\")
+ .trim_end_matches('\\')
+ .to_ascii_lowercase()
+}
+
+fn game_creator_codex_grant_root_is_within_workspace(
+ workspace: &std::path::Path,
+ grant_root: &str,
+) -> bool {
+ fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option {
+ if !path.is_absolute()
+ || path
+ .components()
+ .any(|component| matches!(component, std::path::Component::ParentDir))
+ {
+ return None;
+ }
+ let mut existing = path.to_path_buf();
+ let mut missing_tail = Vec::new();
+ while !existing.exists() {
+ missing_tail.push(existing.file_name()?.to_os_string());
+ if !existing.pop() {
+ return None;
+ }
+ }
+ let mut normalized = existing.canonicalize().ok()?;
+ for component in missing_tail.iter().rev() {
+ normalized.push(component);
+ }
+ Some(normalized)
+ }
+
+ let workspace = workspace
+ .canonicalize()
+ .unwrap_or_else(|_| workspace.to_path_buf());
+ let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else {
+ return false;
+ };
+ let workspace_key = game_creator_codex_workspace_path_key(&workspace);
+ let grant_key = game_creator_codex_workspace_path_key(&grant_root);
+ grant_key == workspace_key
+ || grant_key
+ .strip_prefix(&workspace_key)
+ .is_some_and(|suffix| suffix.starts_with('\\'))
+}
+
+fn game_creator_codex_file_change_request_is_allowed(
+ workspace: &std::path::Path,
+ method: &str,
+ grant_root: Option<&str>,
+) -> bool {
+ method != "item/fileChange/requestApproval"
+ // `grantRoot: null` means the existing turn sandbox root; only an
+ // explicit expansion request needs an extra path check.
+ || grant_root.is_none()
+ || grant_root
+ .is_some_and(|grant_root| game_creator_codex_grant_root_is_within_workspace(workspace, grant_root))
+}
+
async fn fail_game_creator_codex_app_server_connection(
inner: &Weak,
error: String,
@@ -1565,6 +1920,64 @@ pub(in crate::agent) async fn request_game_creator_agent_codex_app_server(
.await
}
+/// Direct product chat path: a Codex app-server turn in the selected project
+/// workspace. The pool keeps one ephemeral thread per project so follow-up
+/// messages retain Codex context while still bypassing the Supervisor/runtime
+/// harness.
+pub(crate) async fn direct_game_creator_codex_chat_at(
+ root: &std::path::Path,
+ system_prompt: String,
+ user_prompt: String,
+) -> Result {
+ let codex_root = if let Some(path) = root
+ .to_str()
+ .and_then(|value| value.strip_prefix("\\\\?\\"))
+ {
+ std::path::PathBuf::from(path)
+ } else {
+ root.to_path_buf()
+ };
+ let config = load_game_creator_app_config()?;
+ game_creator_codex_app_server_validate_llm_config(&config.llm)
+ .map_err(|error| error.to_string())?;
+ let mut digest = Sha256::new();
+ digest.update(root.to_string_lossy().as_bytes());
+ let project_id = format!("{:x}", digest.finalize());
+ let snapshot = AgentRuntimeProviderRequestSnapshot {
+ project_id: project_id.clone(),
+ agent_id: "direct-codex".to_string(),
+ task_id: "direct-codex-chat".to_string(),
+ session_id: format!("direct-codex-session-{project_id}"),
+ run_id: format!("direct-codex-thread-{project_id}"),
+ source: "direct-codex-chat".to_string(),
+ goal_id: None,
+ goal_revision: 0,
+ goal_snapshot_fingerprint: String::new(),
+ applied_steer_cursor: 0,
+ request_kind: "direct-chat".to_string(),
+ request_slot: "direct-chat".to_string(),
+ web_search_enabled: config.llm.web_search_enabled,
+ allow_idle_context_compaction: false,
+ planning_session_binding: None,
+ };
+ let api_kind =
+ parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
+ let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
+ .with_api_kind(api_kind)
+ .with_model(config.llm.model.clone())
+ .with_request_timeout_ms(config.llm.request_timeout_ms)
+ .with_max_output_tokens(16_000);
+ let connection =
+ CodexAppServerConnection::acquire_at_workspace(&snapshot, &config.llm, Some(&codex_root))
+ .await
+ .map_err(|error| error.to_string())?;
+ connection
+ .run_turn(&snapshot, &config.llm, request, None)
+ .await
+ .map(|value| value.text)
+ .map_err(|error| error.to_string())
+}
+
pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
@@ -1647,6 +2060,23 @@ mod tests {
])
}
+ #[test]
+ fn direct_codex_turn_keeps_system_prompt_out_of_user_input() {
+ let request = LlmRunRequest::single_turn("AGC 系统规则", "制作一个可玩的游戏");
+ assert_eq!(direct_codex_base_instructions(&request), "AGC 系统规则");
+ assert_eq!(direct_codex_user_prompt(&request), "制作一个可玩的游戏");
+ assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则"));
+ }
+
+ #[test]
+ fn codex_app_server_reasoning_effort_preserves_max_wire_value() {
+ let request = LlmRunRequest::single_turn("系统", "任务")
+ .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max);
+ let mut params = serde_json::json!({});
+ apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request);
+ assert_eq!(params["effort"], "max");
+ }
+
#[test]
fn codex_app_server_maps_structured_output_to_runtime_tool_calls() {
let text = r#"{"toolCalls":[{"name":"runtime_tool_file_read","arguments":"{\"path\":\"game/index.html\"}"}]}"#;
@@ -1743,6 +2173,66 @@ mod tests {
assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err());
}
+ #[test]
+ fn direct_debug_redaction_does_not_retain_inline_credentials() {
+ let text = redact_game_creator_codex_debug_text(
+ "Bearer secret apiKey=one token=two access_token=three",
+ );
+ assert!(!text.contains("secret"));
+ assert!(!text.contains("one"));
+ assert!(!text.contains("two"));
+ assert!(!text.contains("three"));
+ assert!(text.contains("[redacted]"));
+ }
+
+ #[test]
+ fn direct_file_change_approval_is_limited_to_workspace() {
+ let temp = tempfile::tempdir().expect("temp dir");
+ let workspace = temp.path().join("demo");
+ let child = workspace.join("game");
+ let sibling = temp.path().join("demolition");
+ std::fs::create_dir_all(&child).expect("workspace child");
+ std::fs::create_dir(&sibling).expect("sibling");
+ assert!(game_creator_codex_file_change_request_is_allowed(
+ &workspace,
+ "item/fileChange/requestApproval",
+ None
+ ));
+ assert!(game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ workspace.to_string_lossy().as_ref()
+ ));
+ assert!(game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ child.to_string_lossy().as_ref()
+ ));
+ assert!(!game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ sibling.to_string_lossy().as_ref()
+ ));
+ assert!(!game_creator_codex_file_change_request_is_allowed(
+ &workspace,
+ "item/fileChange/requestApproval",
+ Some(sibling.to_string_lossy().as_ref())
+ ));
+ assert!(!game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ sibling.join("missing").to_string_lossy().as_ref()
+ ));
+ assert!(game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ workspace.join("missing").to_string_lossy().as_ref()
+ ));
+ assert!(!game_creator_codex_grant_root_is_within_workspace(
+ &workspace,
+ workspace
+ .join("..")
+ .join("outside")
+ .to_string_lossy()
+ .as_ref()
+ ));
+ }
+
#[test]
fn codex_app_server_command_uses_only_current_cli_feature_flags() {
let mut command = tokio::process::Command::new("codex");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
index ca5ce15c3..8155044a6 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
@@ -144,6 +144,7 @@ pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort(
platform_llm::LlmResponseReasoningEffort::Low => "low",
platform_llm::LlmResponseReasoningEffort::Medium => "medium",
platform_llm::LlmResponseReasoningEffort::High => "high",
+ platform_llm::LlmResponseReasoningEffort::Max => "max",
})
}
@@ -666,6 +667,16 @@ mod tests {
])
}
+ #[test]
+ fn codex_cli_reasoning_effort_preserves_max_wire_value() {
+ let request = LlmRunRequest::single_turn("系统", "任务")
+ .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max);
+ assert_eq!(
+ game_creator_codex_cli_reasoning_effort(&request),
+ Some("max")
+ );
+ }
+
#[cfg(windows)]
#[test]
fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs
new file mode 100644
index 000000000..053c13cfb
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs
@@ -0,0 +1,2870 @@
+use super::*;
+use std::io::Write;
+use std::path::Path;
+
+const MAX_CONTEXT_FILE_BYTES: usize = 6 * 1024;
+const MAX_DIRECT_PROJECT_FILE_BYTES: usize = 24 * 1024;
+const MAX_CONTEXT_FILES_PER_SOURCE: usize = 6;
+const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024;
+const BUNDLED_REPOSITORY_AGENTS: &str = include_str!("../../../../../AGENTS.md");
+const BUNDLED_AGC_IMPLEMENTATION_PLAN: &str = include_str!(
+ "../../../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md"
+);
+const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
+const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
+const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
+const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str =
+ "platform-generation-source-preserved-no-retry:";
+#[cfg(test)]
+const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [
+ ("player", "assets/art-spritesheet-slices/player.png"),
+ (
+ "blocks-and-targets",
+ "assets/art-spritesheet-slices/blocks-and-targets.png",
+ ),
+ (
+ "obstacles-and-scene",
+ "assets/art-spritesheet-slices/obstacles-and-scene.png",
+ ),
+ (
+ "feedback-effects",
+ "assets/art-spritesheet-slices/feedback-effects.png",
+ ),
+];
+const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+];
+const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [
+ ("game/index.html", "game-entry", "text/html"),
+ ("game/style.css", "game-style", "text/css"),
+ ("game/game.js", "game-script", "text/javascript"),
+];
+
+fn direct_existing_game_sources_exist(root: &Path) -> bool {
+ DIRECT_CODEX_GAME_OUTPUTS
+ .iter()
+ .all(|(path, _, _)| root.join(path).is_file())
+}
+
+/// Art generation is an external, billable side effect. Existing direct
+/// projects therefore stay in their same-thread code-edit/preview loop unless
+/// the user explicitly asks for a new game or a visual regeneration.
+fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool {
+ let prompt = prompt.trim().to_lowercase();
+ [
+ "新游戏",
+ "新建",
+ "从头",
+ "重新生成",
+ "重做美术",
+ "换一套美术",
+ "重新生图",
+ "生成美术",
+ "做素材",
+ ]
+ .iter()
+ .any(|marker| prompt.contains(marker))
+}
+const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台准备已登记的真实美术资源。优先使用规范图、场景背景和可用图集/切片;如果平台只返回完整透明图集或某些切片缺失,不要伪造切片,也不要因此停止生成。至少让一个真实平台素材进入实际渲染路径,并通过真实浏览器试玩检查画面和交互。不要用 emoji、纯色块或 CSS 图形冒充平台核心美术,也不要只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。收到客户端试玩证据后,必须读取实际文件并按证据继续修复;最终回复结构化列出检查文件、真实启动或试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题和剩余风险。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。";
+const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先准备并登记平台美术资源。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。规范图用于统一视觉风格;背景、完整图集和平台返回的切片可按实际可用性接入。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替真实平台素材。至少一个已登记平台图片必须被游戏源码引用并在页面中实际加载。";
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct DirectTaonierArtAssetIdentity {
+ resource_id: String,
+ asset_object_id: String,
+ canvas_project_id: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum DirectCodexFailureStage {
+ ArtPreparation,
+ CodeGeneration,
+ BrowserValidation,
+ VersionRegistration,
+}
+
+impl DirectCodexFailureStage {
+ fn id(self) -> &'static str {
+ match self {
+ Self::ArtPreparation => "art-preparation",
+ Self::CodeGeneration => "code-generation",
+ Self::BrowserValidation => "browser-validation",
+ Self::VersionRegistration => "version-registration",
+ }
+ }
+}
+
+#[derive(Debug)]
+struct DirectCodexTurnFailure {
+ stage: DirectCodexFailureStage,
+ error: String,
+}
+
+impl DirectCodexTurnFailure {
+ fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self {
+ Self {
+ stage,
+ error: error.into(),
+ }
+ }
+}
+
+fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str {
+ let normalized = error.to_ascii_lowercase();
+ if private_external_editor_credentials_storage_preparation_failed(error) {
+ return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试";
+ }
+ if private_external_editor_credentials_persistence_failed(error) {
+ return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试";
+ }
+ if error.contains("本机陶泥儿开发者 Key") {
+ return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key";
+ }
+ if normalized.contains("authentication-required")
+ || normalized.contains("unauthorized")
+ || normalized.contains("http 401")
+ {
+ return "登录态可能已失效,请重新登录陶泥儿后重试";
+ }
+ if normalized.contains("permission-denied") || normalized.contains("http 403") {
+ return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
+ }
+ if error.contains("项目正在被其他写操作占用") {
+ return "当前项目仍有写入正在结束,请稍后再次发送该需求";
+ }
+ if error.contains("身份不唯一")
+ || error.contains("身份不匹配")
+ || error.contains("未找到身份完整的历史图集")
+ || error.contains("没有可见像素")
+ {
+ return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集";
+ }
+ match stage {
+ DirectCodexFailureStage::ArtPreparation => {
+ "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断"
+ }
+ DirectCodexFailureStage::CodeGeneration => {
+ "Codex 未完成本轮代码修改,请检查运行时配置后重试"
+ }
+ DirectCodexFailureStage::BrowserValidation => {
+ "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求"
+ }
+ DirectCodexFailureStage::VersionRegistration => {
+ "产物尚未安全登记为版本,请检查项目目录后重试"
+ }
+ }
+}
+
+fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
+ if private_external_editor_credentials_storage_preparation_failed(error) {
+ return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据");
+ }
+ if private_external_editor_credentials_persistence_failed(error) {
+ return Some("本机开发者凭据已创建但未能安全保存");
+ }
+ None
+}
+
+fn direct_codex_failure_is_retryable(error: &str) -> bool {
+ ![
+ "private-external-editor-credential-storage-preparation-failed",
+ "private-external-editor-credential-persistence-failed",
+ "身份不唯一",
+ "身份不匹配",
+ "未找到身份完整的历史图集",
+ "没有可见像素",
+ "合同发生变化",
+ ]
+ .iter()
+ .any(|marker| error.contains(marker))
+}
+
+fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String {
+ let summary = direct_codex_failure_public_summary(&failure.error)
+ .map(str::to_string)
+ .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320));
+ let summary = summary.split_whitespace().collect::>().join(" ");
+ let summary = if summary.trim().is_empty() {
+ "未提供可安全展示的详细原因".to_string()
+ } else {
+ summary
+ };
+ let retryable = direct_codex_failure_is_retryable(&failure.error);
+ let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error);
+ let diagnostic = serde_json::json!({
+ "schemaVersion": "direct-codex-diagnostic.v1",
+ "stage": failure.stage.id(),
+ "summary": summary,
+ "retryable": retryable,
+ "recoveryHint": recovery_hint,
+ "occurredAtUnixNanos": unix_timestamp_nanos().to_string(),
+ });
+ let relative_path = format!(
+ ".agent/runtime/direct-codex-diagnostics/{}/failure.json",
+ unix_timestamp_nanos()
+ );
+ let diagnostic_written = write_agent_runtime_json_sidecar_with_max_bytes(
+ root,
+ &relative_path,
+ "直连 Codex 失败诊断",
+ &diagnostic,
+ 8 * 1024,
+ )
+ .is_ok();
+ let diagnostics_suffix = if diagnostic_written {
+ "已保存脱敏项目诊断"
+ } else {
+ "未能保存项目诊断"
+ };
+ format!(
+ "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}",
+ failure.stage.id(),
+ retryable,
+ diagnostic["summary"]
+ .as_str()
+ .unwrap_or("未提供可安全展示的详细原因"),
+ recovery_hint,
+ diagnostics_suffix,
+ )
+}
+
+fn direct_taonier_art_generation_runtime_context(
+ root: &Path,
+ output_path: &str,
+ asset_kind: &str,
+) -> Result {
+ let project_id = read_manifest(&root.join(".agent/manifest.json"))?
+ .project_id
+ .trim()
+ .to_string();
+ if project_id.is_empty() {
+ return Err("直连美术生成缺少项目身份,已拒绝创建平台请求".to_string());
+ }
+ let stage = match output_path {
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH => "art-spec",
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH => "game-background",
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH => "art-spritesheet",
+ _ => return Err("直连美术生成请求包含未声明的输出路径".to_string()),
+ };
+ Ok(PlatformArtGenerationRuntimeContext {
+ agent_id: "direct-codex-art".to_string(),
+ task_id: format!("direct-codex-art-{stage}"),
+ session_id: project_id,
+ run_id: stage.to_string(),
+ source: "direct-codex".to_string(),
+ action_id: format!("direct-taonier-{stage}"),
+ action_fingerprint: format!("direct-taonier-art-v1:{asset_kind}:{stage}"),
+ })
+}
+
+fn direct_taonier_art_asset_identity(
+ root: &Path,
+ expected_path: &str,
+ expected_kind: &str,
+ expected_generation_route: &str,
+ expected_generation_kind: &str,
+ expected_reference_resource_ids: &[&str],
+ expected_canvas_project_id: Option<&str>,
+) -> Option {
+ let manifest = read_manifest(&root.join(".agent/manifest.json")).ok()?;
+ manifest.assets.iter().find_map(|asset| {
+ if asset.local_path != expected_path
+ || asset.kind != expected_kind
+ || !asset.media_type.starts_with("image/")
+ || asset.source.kind != GameCreationAppAssetSourceKind::Canvas
+ || asset.source.generation_route.as_deref() != Some(expected_generation_route)
+ || asset.source.generation_kind.as_deref() != Some(expected_generation_kind)
+ || asset.source.reference_resource_ids.len() != expected_reference_resource_ids.len()
+ || !asset
+ .source
+ .reference_resource_ids
+ .iter()
+ .map(String::as_str)
+ .eq(expected_reference_resource_ids.iter().copied())
+ || expected_canvas_project_id
+ .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected))
+ {
+ return None;
+ }
+ let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?;
+ if !asset_path.is_file() {
+ return None;
+ }
+ let bytes = std::fs::read(asset_path).ok()?;
+ let validated = validate_platform_art_png_bytes_with_limits(
+ &bytes,
+ &format!("陶泥儿平台素材 {}", asset.local_path),
+ )
+ .ok()?;
+ if !validated.has_visible_pixels {
+ return None;
+ }
+ Some(DirectTaonierArtAssetIdentity {
+ resource_id: asset
+ .source
+ .resource_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())?
+ .to_string(),
+ asset_object_id: asset
+ .source
+ .asset_object_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())?
+ .to_string(),
+ canvas_project_id: asset
+ .source
+ .canvas_project_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())?
+ .to_string(),
+ })
+ })
+}
+
+fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
+ let Some(art_spec) = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ None,
+ ) else {
+ return false;
+ };
+ let Some(background) = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ "game-background",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[&art_spec.resource_id],
+ Some(&art_spec.canvas_project_id),
+ ) else {
+ return false;
+ };
+ let _ = (background, art_spec);
+ true
+}
+
+fn direct_taonier_art_package_is_valid(root: &Path) -> bool {
+ let Some(art_spec) = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ None,
+ ) else {
+ return false;
+ };
+ if !direct_taonier_art_base_is_valid(root) {
+ return false;
+ }
+ let Some(spritesheet) = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "art-spritesheet",
+ "/api/external/v1/editor/icon-spritesheets/generations",
+ "icon-spritesheet",
+ &[&art_spec.resource_id],
+ Some(&art_spec.canvas_project_id),
+ ) else {
+ return false;
+ };
+ let _ = (spritesheet, art_spec);
+ true
+}
+
+fn direct_asset_file_name(path: &str) -> &str {
+ Path::new(path)
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or(path)
+}
+
+fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<&'static str> {
+ let sources = ["game/index.html", "game/style.css", "game/game.js"]
+ .iter()
+ .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok())
+ .collect::>();
+ let mut available_paths = Vec::new();
+ if direct_taonier_art_base_is_valid(root) {
+ available_paths.push(DIRECT_CODEX_ART_SPEC_ASSET_PATH);
+ available_paths.push(DIRECT_CODEX_BACKGROUND_ASSET_PATH);
+ }
+ if direct_taonier_art_package_is_valid(root) {
+ available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH);
+ }
+ available_paths
+ .into_iter()
+ .filter(|path| {
+ sources.iter().any(|source| {
+ source.contains(path) || source.contains(direct_asset_file_name(path))
+ })
+ })
+ .collect()
+}
+
+fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool {
+ !direct_game_sources_referenced_taonier_assets(root).is_empty()
+}
+
+fn direct_normalized_project_asset_path(path: &str) -> Option {
+ let normalized = path.trim().replace('\\', "/");
+ let normalized = normalized.split(['?', '#']).next().unwrap_or_default();
+ let normalized = normalized.trim_start_matches('/');
+ (!normalized.is_empty() && !normalized.contains("..")).then(|| normalized.to_string())
+}
+
+const DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX: &str = "[advisory] rendered-local-images:";
+
+fn direct_browser_rendered_image_paths(viewport: &BrowserViewportValidationResult) -> Vec<&str> {
+ viewport
+ .diagnostics
+ .iter()
+ .filter_map(|diagnostic| {
+ diagnostic.strip_prefix(DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX)
+ })
+ .flat_map(|paths| paths.split('|'))
+ .map(str::trim)
+ .filter(|path| !path.is_empty())
+ .collect()
+}
+
+fn direct_rendered_taonier_assets_in_viewport(
+ root: &Path,
+ viewport: &BrowserViewportValidationResult,
+) -> Vec<&'static str> {
+ let referenced = direct_game_sources_referenced_taonier_assets(root);
+ referenced
+ .into_iter()
+ .filter(|asset_path| {
+ direct_browser_rendered_image_paths(viewport)
+ .iter()
+ .any(|rendered_path| {
+ direct_normalized_project_asset_path(rendered_path)
+ .as_deref()
+ .is_some_and(|rendered_path| {
+ rendered_path == *asset_path
+ || rendered_path.ends_with(&format!("/{asset_path}"))
+ })
+ })
+ })
+ .collect()
+}
+
+fn direct_browser_evidence_needs_art_repair(
+ root: &Path,
+ evidence: Option<&BrowserValidationResult>,
+) -> bool {
+ let Some(evidence) = evidence else {
+ return false;
+ };
+ evidence.passed
+ && evidence
+ .viewport_results
+ .iter()
+ .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty())
+}
+
+/// The only output-shape proof the direct Runtime owns. It deliberately does
+/// not prescribe a spritesheet layout, slice names, `drawImage` count, or a
+/// particular canvas implementation: those are quality questions for the
+/// same Codex thread after it has seen real browser evidence.
+fn direct_game_output_completion_error(root: &Path) -> Option {
+ if !root.join("game/index.html").is_file() {
+ return Some("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string());
+ }
+ if !direct_game_sources_reference_taonier_art_package(root) {
+ return Some(
+ "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成"
+ .to_string(),
+ );
+ }
+ None
+}
+
+fn direct_acceptance_needs_repair(
+ root: &Path,
+ evidence: Option<&BrowserValidationResult>,
+ completion_error: Option<&str>,
+) -> bool {
+ completion_error.is_some()
+ || evidence.is_none_or(|result| !result.passed)
+ || direct_browser_evidence_needs_art_repair(root, evidence)
+}
+
+fn direct_recoverable_spritesheet_resource<'a>(
+ resources: &'a [serde_json::Value],
+ art_spec: &DirectTaonierArtAssetIdentity,
+) -> Result, String> {
+ let mut candidates = resources.iter().filter(|resource| {
+ let kind = json_string_field(resource, "assetKind")
+ .or_else(|| json_string_field(resource, "kind"));
+ let source_resource_id = json_string_field(resource, "sourceResourceId");
+ let referenced_spec_ids = resource
+ .pointer("/generationInputs/references")
+ .and_then(serde_json::Value::as_array)
+ .map(|references| {
+ references
+ .iter()
+ .filter_map(|reference| {
+ json_string_field(reference, "refId")
+ .or_else(|| json_string_field(reference, "resourceId"))
+ .or_else(|| json_string_field(reference, "id"))
+ })
+ .collect::>()
+ });
+ let bound_to_current_spec = match source_resource_id {
+ Some(source_resource_id) => source_resource_id == art_spec.resource_id,
+ // The platform's source-only fallback preserves the generated
+ // spritesheet as the primary resource and therefore has no
+ // sourceResourceId. Its immutable request reference is an
+ // equivalent identity only when it contains exactly the current
+ // spec and no other resource.
+ None => referenced_spec_ids.as_ref().is_some_and(|references| {
+ references.len() == 1 && references[0] == art_spec.resource_id
+ }),
+ };
+ kind.as_deref() == Some("icon-spritesheet")
+ && json_string_field(resource, "projectId").as_deref()
+ == Some(art_spec.canvas_project_id.as_str())
+ && bound_to_current_spec
+ });
+ let Some(resource) = candidates.next() else {
+ return Ok(None);
+ };
+ if candidates.next().is_some() {
+ return Err("陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复".to_string());
+ }
+ if json_string_field(resource, "generationRoute")
+ .or_else(|| json_string_field(resource, "generation_route"))
+ .is_some_and(|route| route != "/api/external/v1/editor/icon-spritesheets/generations")
+ || json_string_field(resource, "generationKind")
+ .or_else(|| json_string_field(resource, "generation_kind"))
+ .is_some_and(|kind| kind != "icon-spritesheet")
+ {
+ return Err("匹配到的陶泥儿图集资源生成身份不匹配,已拒绝恢复".to_string());
+ }
+ Ok(Some(resource))
+}
+
+async fn recover_direct_taonier_spritesheet_read_only_at(
+ root: &Path,
+ art_spec: &DirectTaonierArtAssetIdentity,
+) -> Result {
+ let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?;
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(60))
+ .build()
+ .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?;
+ let route = resolve_platform_editor_api_route(&format!(
+ "/api/external/v1/editor/projects/{}",
+ percent_encode_query_component(&art_spec.canvas_project_id)
+ ));
+ let response = client
+ .get(format!("{api_base_url}{route}"))
+ .bearer_auth(&api_key)
+ .send()
+ .await
+ .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?;
+ let status = response.status();
+ if !status.is_success() {
+ return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16()));
+ }
+ let payload = response
+ .json::()
+ .await
+ .map_err(|error| format!("解析陶泥儿画布资源失败:{error}"))?;
+ let project = payload
+ .get("project")
+ .or_else(|| payload.pointer("/data/project"))
+ .ok_or_else(|| "陶泥儿画布资源响应缺少 project".to_string())?;
+ let resources = project
+ .get("resources")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?;
+ let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else {
+ return Ok(false);
+ };
+ let resource_id = json_string_field(resource, "resourceId")
+ .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 resourceId".to_string())?;
+ let asset_object_id = json_string_field(resource, "assetObjectId")
+ .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 assetObjectId".to_string())?;
+ let task_id = json_string_field(resource, "taskId")
+ .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 taskId".to_string())?;
+ let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource)
+ .await?
+ .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?;
+ let validated = validate_platform_art_png_bytes_with_limits(
+ &download.bytes,
+ "恢复的陶泥儿 art-spritesheet",
+ )?;
+ if !validated.has_visible_pixels {
+ return Err("恢复的陶泥儿 art-spritesheet 没有可见像素".to_string());
+ }
+ let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
+ root,
+ "direct-codex.art-spritesheet-recovery",
+ )?;
+ let current_art_spec = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ Some(&art_spec.canvas_project_id),
+ )
+ .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?;
+ if current_art_spec != *art_spec {
+ return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string());
+ }
+ let output = resolve_local_project_path(root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH)?;
+ if output.exists() {
+ return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string());
+ }
+ if let Some(parent) = output.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("创建陶泥儿图集目录失败:{error}"))?;
+ }
+ let mut output_file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&output)
+ .map_err(|error| format!("创建恢复的陶泥儿图集文件失败:{error}"))?;
+ output_file
+ .write_all(&download.bytes)
+ .and_then(|_| output_file.sync_all())
+ .map_err(|error| {
+ let _ = std::fs::remove_file(&output);
+ format!("写入恢复的陶泥儿图集失败:{error}")
+ })?;
+ drop(output_file);
+ let register = register_local_asset_at(
+ root,
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "art-spritesheet",
+ "image/png",
+ "platform-art-recovery",
+ GameCreationAppAssetSource {
+ kind: GameCreationAppAssetSourceKind::Canvas,
+ canvas_project_id: Some(art_spec.canvas_project_id.clone()),
+ resource_id: Some(resource_id),
+ asset_object_id: Some(asset_object_id),
+ task_id: Some(task_id),
+ prompt: json_string_field(resource, "actualPrompt")
+ .or_else(|| json_string_field(resource, "prompt")),
+ model: json_string_field(resource, "model"),
+ generation_route: Some(
+ "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
+ ),
+ generation_kind: Some("icon-spritesheet".to_string()),
+ reference_resource_ids: vec![art_spec.resource_id.clone()],
+ },
+ );
+ if let Err(error) = register {
+ let _ = std::fs::remove_file(&output);
+ return Err(error);
+ }
+ emit_direct_game_creator_progress(
+ root,
+ "art.spritesheet.recovered",
+ "已从陶泥儿画布只读恢复核心图集",
+ );
+ Ok(true)
+}
+
+fn direct_spritesheet_source_preserved_failure(output_path: &str, error: &str) -> bool {
+ output_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH
+ && error
+ .trim_start()
+ .starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX)
+}
+
+fn clear_direct_taonier_generation_ledger_after_source_recovery(
+ root: &Path,
+ runtime_context: &PlatformArtGenerationRuntimeContext,
+) -> Result<(), String> {
+ remove_platform_art_generation_runtime_state_at(
+ root,
+ &runtime_context.agent_id,
+ &runtime_context.run_id,
+ )
+ .map_err(|error| format!("已恢复平台源图,但清理已完成生成账本失败:{error}"))
+}
+
+/// A postprocess fallback has already completed the paid platform generation
+/// and preserved its source resource. The direct Runtime may only make a
+/// bounded, read-only attempt to recover that exact resource; it must never
+/// turn this branch into another generation submission.
+async fn recover_direct_taonier_spritesheet_after_source_preserved_failure_at(
+ root: &Path,
+ art_spec: &DirectTaonierArtAssetIdentity,
+) -> Result {
+ const MAX_READ_ONLY_ATTEMPTS: usize = 3;
+
+ for attempt in 0..MAX_READ_ONLY_ATTEMPTS {
+ if recover_direct_taonier_spritesheet_read_only_at(root, art_spec).await? {
+ return Ok(true);
+ }
+ if attempt + 1 < MAX_READ_ONLY_ATTEMPTS {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ }
+ }
+ Ok(false)
+}
+
+async fn generate_direct_taonier_art_asset_at(
+ root: &Path,
+ prompt: &str,
+ output_path: &str,
+ aspect_ratio: &str,
+ asset_kind: &str,
+ asset_label: &str,
+ require_slices: bool,
+) -> Result<(), String> {
+ let options = PlatformArtAssetGenerationOptions {
+ output_path: Some(output_path.to_string()),
+ aspect_ratio: aspect_ratio.to_string(),
+ image_size: "1K".to_string(),
+ asset_kind: asset_kind.to_string(),
+ asset_label: asset_label.to_string(),
+ replace_existing: root.join(output_path).is_file(),
+ };
+ let runtime_context =
+ direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
+ let generated = match generate_platform_art_asset_with_runtime_options_at(
+ root,
+ prompt,
+ &[],
+ &options,
+ require_slices,
+ &runtime_context,
+ )
+ .await
+ {
+ Ok(generated) => generated,
+ Err(error) if direct_spritesheet_source_preserved_failure(output_path, &error) => {
+ let art_spec = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ None,
+ )
+ .ok_or_else(|| {
+ "陶泥儿核心图集透明后处理失败,但当前规范图不再满足来源、下载或解码校验,已拒绝恢复源图"
+ .to_string()
+ })?;
+ let recovered = recover_direct_taonier_spritesheet_after_source_preserved_failure_at(
+ root, &art_spec,
+ )
+ .await
+ .map_err(|recovery_error| {
+ format!(
+ "陶泥儿核心图集透明后处理失败后无法安全恢复已保留源图:{recovery_error};原始结果:{error}"
+ )
+ })?;
+ if !recovered {
+ return Err(format!(
+ "陶泥儿核心图集透明后处理失败,平台已保留源图但在有界只读查询内未找到可安全恢复的唯一资源:{error}"
+ ));
+ }
+ clear_direct_taonier_generation_ledger_after_source_recovery(root, &runtime_context)?;
+ emit_direct_game_creator_progress(
+ root,
+ "art.spritesheet.source-recovered",
+ "透明后处理未完成,已安全恢复陶泥儿保留的核心源图",
+ );
+ return Ok(());
+ }
+ Err(error) => {
+ return Err(format!(
+ "陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}"
+ ));
+ }
+ };
+ if generated.asset.local_path != output_path {
+ return Err(format!(
+ "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成"
+ ));
+ }
+ Ok(())
+}
+
+async fn ensure_direct_taonier_art_package_at(
+ root: &Path,
+ prompt: &str,
+) -> Result, String> {
+ emit_direct_game_creator_progress(root, "art.prepare", "正在检查陶泥儿美术包");
+ if direct_taonier_art_package_is_valid(root) {
+ emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
+ return Ok(DIRECT_CODEX_ART_ASSET_PATHS
+ .iter()
+ .map(|path| (*path).to_string())
+ .collect());
+ }
+ let existing_art_spec = direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ None,
+ );
+ let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
+ direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ "game-background",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[&art_spec.resource_id],
+ Some(&art_spec.canvas_project_id),
+ )
+ .is_some()
+ });
+ let art_spec = match existing_art_spec {
+ Some(identity) => identity,
+ None => {
+ emit_direct_game_creator_progress(root, "art.spec", "正在生成统一视觉规范图");
+ generate_direct_taonier_art_asset_at(
+ root,
+ prompt,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "1:1",
+ "icon-spec",
+ "陶泥儿首版游戏视觉规范图",
+ false,
+ )
+ .await?;
+ emit_direct_game_creator_progress(root, "art.spec.ready", "统一视觉规范图已生成");
+ direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[],
+ None,
+ )
+ .ok_or_else(|| "陶泥儿规范图生成后未形成可用平台合同,已终止代码生成".to_string())?
+ }
+ };
+ if direct_taonier_art_asset_identity(
+ root,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ "game-background",
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ &[&art_spec.resource_id],
+ Some(&art_spec.canvas_project_id),
+ )
+ .is_none()
+ {
+ emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
+ generate_direct_taonier_art_asset_at(
+ root,
+ prompt,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ "16:9",
+ "game-background",
+ "陶泥儿首版游戏场景背景",
+ false,
+ )
+ .await?;
+ emit_direct_game_creator_progress(root, "art.background.ready", "游戏场景背景图已生成");
+ }
+ if direct_taonier_art_package_is_valid(root) {
+ emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
+ return Ok(DIRECT_CODEX_ART_ASSET_PATHS
+ .iter()
+ .map(|path| (*path).to_string())
+ .collect());
+ }
+ if recover_direct_taonier_spritesheet_read_only_at(root, &art_spec).await? {
+ if direct_taonier_art_package_is_valid(root) {
+ emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
+ return Ok(DIRECT_CODEX_ART_ASSET_PATHS
+ .iter()
+ .map(|path| (*path).to_string())
+ .collect());
+ }
+ }
+ if existing_art_spec_and_background {
+ if !direct_taonier_art_base_is_valid(root) {
+ return Err("当前项目已有的陶泥儿规范图或背景图不再满足可下载、可解码和来源一致性要求,已终止代码生成".to_string());
+ }
+ emit_direct_game_creator_progress(
+ root,
+ "art.ready",
+ "核心图集未能安全恢复,已使用现有陶泥儿规范图和背景图继续创作",
+ );
+ return Ok(vec![
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH.to_string(),
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH.to_string(),
+ ]);
+ }
+ emit_direct_game_creator_progress(
+ root,
+ "art.spritesheet",
+ "正在生成核心图集并切分四类运行时素材",
+ );
+ generate_direct_taonier_art_asset_at(
+ root,
+ prompt,
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "1:1",
+ "art-spritesheet",
+ "陶泥儿首版核心游戏美术图集",
+ false,
+ )
+ .await?;
+ if !direct_taonier_art_package_is_valid(root) {
+ return Err("陶泥儿美术包缺少可用的已登记主图,已终止代码生成".to_string());
+ }
+ emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
+ Ok(DIRECT_CODEX_ART_ASSET_PATHS
+ .iter()
+ .map(|path| (*path).to_string())
+ .collect())
+}
+
+fn direct_codex_generated_source() -> GameCreationAppAssetSource {
+ GameCreationAppAssetSource {
+ kind: GameCreationAppAssetSourceKind::Generated,
+ canvas_project_id: None,
+ resource_id: None,
+ asset_object_id: None,
+ task_id: Some("code-prototype".to_string()),
+ prompt: None,
+ model: None,
+ generation_route: Some("direct-codex".to_string()),
+ generation_kind: Some("project-file".to_string()),
+ reference_resource_ids: Vec::new(),
+ }
+}
+
+fn direct_codex_output_fingerprint(root: &Path) -> String {
+ let mut hasher = Sha256::new();
+ for (local_path, _, _) in DIRECT_CODEX_GAME_OUTPUTS {
+ hasher.update(local_path.as_bytes());
+ hasher.update([0]);
+ match std::fs::read(root.join(local_path)) {
+ Ok(bytes) => {
+ hasher.update([1]);
+ hasher.update((bytes.len() as u64).to_le_bytes());
+ hasher.update(bytes);
+ }
+ Err(_) => hasher.update([0]),
+ }
+ }
+ format!("{:x}", hasher.finalize())
+}
+
+fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result {
+ let revision = read_game_creator_agent_runtime_project_revision(root)
+ .map(|value| value.revision)
+ .unwrap_or(0);
+ resolve_local_project_path(
+ root,
+ &format!(".agent/runtime/direct-codex-browser-validation/{revision}/attempt-{attempt}"),
+ )
+}
+
+fn direct_browser_viewport_evidence(
+ root: &Path,
+ result: &BrowserViewportValidationResult,
+) -> String {
+ let screenshot = result
+ .screenshot_path
+ .strip_prefix(root)
+ .ok()
+ .and_then(|path| path.to_str())
+ .map(|path| path.replace('\\', "/"))
+ .unwrap_or_else(|| "evidence-screenshot.png".to_string());
+ let canvas_non_empty = result
+ .canvases
+ .iter()
+ .filter(|canvas| canvas.non_empty == Some(true))
+ .count();
+ let fatal_requests = result
+ .failed_requests
+ .iter()
+ .filter(|request| request.fatal)
+ .count();
+ format!(
+ "{}: passed={}, readyState={}, canvas={}/{} nonEmpty, consoleErrors={}, exceptions={}, fatalRequests={}, screenshot={}",
+ match result.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ },
+ result.passed,
+ result.ready_state,
+ canvas_non_empty,
+ result.canvases.len(),
+ result.console_errors.len(),
+ result.exceptions.len(),
+ fatal_requests,
+ screenshot,
+ )
+}
+
+fn direct_browser_evidence_prompt(
+ root: &Path,
+ result: Option<&BrowserValidationResult>,
+ attempt: usize,
+ output_changed: bool,
+ completion_error: Option<&str>,
+) -> String {
+ let completion_status = completion_error
+ .map(|error| {
+ format!(
+ "未通过:{}。这是客户端最低完成证明,不是可忽略的建议;请直接修改真实 `game/` 文件后再回复。",
+ redact_agent_runtime_error(root, error, 320)
+ )
+ })
+ .unwrap_or_else(|| {
+ "通过:已找到 `game/index.html`,且源码至少引用了一份已登记的陶泥儿平台图片。"
+ .to_string()
+ });
+ let (browser_status, viewport_lines, details, advisory) = match result {
+ Some(result) => {
+ let viewport_lines = result
+ .viewport_results
+ .iter()
+ .map(|viewport| direct_browser_viewport_evidence(root, viewport))
+ .collect::>();
+ let art_observations = result
+ .viewport_results
+ .iter()
+ .map(|viewport| {
+ let viewport_name = match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ };
+ let rendered = direct_rendered_taonier_assets_in_viewport(root, viewport);
+ if rendered.is_empty() {
+ format!(
+ "{viewport_name}: 未在 Canvas/WebGL 渲染调用中观察到已登记陶泥儿图片;旁侧缩略图不能证明素材进入核心玩法,源码引用也不能代替运行时证据"
+ )
+ } else {
+ format!(
+ "{viewport_name}: Canvas/WebGL 渲染调用观察到陶泥儿图片 {}",
+ rendered.join("、")
+ )
+ }
+ })
+ .collect::>();
+ let advisory = result
+ .viewport_results
+ .iter()
+ .flat_map(|viewport| viewport.diagnostics.iter())
+ .filter(|diagnostic| diagnostic.starts_with("[advisory]"))
+ .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 240))
+ .collect::>();
+ let details = result
+ .viewport_results
+ .iter()
+ .flat_map(|viewport| {
+ let viewport_name = match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ };
+ let mut details = Vec::new();
+ details.extend(
+ viewport
+ .diagnostics
+ .iter()
+ .filter(|diagnostic| !diagnostic.starts_with("[advisory]"))
+ .map(|diagnostic| {
+ format!(
+ "{viewport_name} diagnostic: {}",
+ redact_agent_runtime_error(root, diagnostic, 320)
+ )
+ }),
+ );
+ details.extend(viewport.console_errors.iter().take(4).map(|message| {
+ format!(
+ "{viewport_name} console.error: {}",
+ redact_agent_runtime_error(root, &message.text, 320)
+ )
+ }));
+ details.extend(viewport.exceptions.iter().take(4).map(|exception| {
+ format!(
+ "{viewport_name} exception: {}",
+ redact_agent_runtime_error(root, &exception.text, 320)
+ )
+ }));
+ details.extend(
+ viewport
+ .failed_requests
+ .iter()
+ .filter(|request| request.fatal)
+ .take(4)
+ .map(|request| {
+ format!(
+ "{viewport_name} fatal request: {} {} ({})",
+ request.method,
+ redact_agent_runtime_error(root, &request.url, 320),
+ redact_agent_runtime_error(root, &request.error_text, 220),
+ )
+ }),
+ );
+ details
+ })
+ .collect::>();
+ (
+ format!("整体 passed={}", result.passed),
+ viewport_lines,
+ {
+ let mut details = details;
+ details.extend(art_observations);
+ details
+ },
+ advisory,
+ )
+ }
+ None => (
+ "本次未启动 Chromium:最低完成证明尚未满足。不得把这当作试玩通过;修复后客户端才会启动真实 desktop/mobile 试玩。".to_string(),
+ Vec::new(),
+ Vec::new(),
+ Vec::new(),
+ ),
+ };
+ format!(
+ "请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n请读取当前工作区的实际 `game/` 文件和可用 `assets/`,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。",
+ viewport_lines.join("\n"),
+ if details.is_empty() {
+ "无额外硬失败详情".to_string()
+ } else {
+ details.join("\n")
+ },
+ if advisory.is_empty() {
+ "未找到可执行的可见控件".to_string()
+ } else {
+ advisory.join(";")
+ },
+ )
+}
+
+async fn run_direct_browser_evidence_at(
+ root: &Path,
+ attempt: usize,
+) -> Result {
+ let evidence_root = direct_browser_evidence_root(root, attempt)?;
+ let (preview, stop_sender) = start_local_game_preview_for_project(root)?;
+ let validation = validate_local_preview_in_browser_with_interaction(
+ BrowserValidationInput {
+ url: preview.url,
+ viewports: vec![
+ BrowserValidationViewport::Desktop,
+ BrowserValidationViewport::Mobile,
+ ],
+ expected_text: Vec::new(),
+ settle_ms: 800,
+ fail_on_console_error: true,
+ playtest_scenario: None,
+ evidence_root,
+ },
+ true,
+ )
+ .await;
+ let _ = stop_sender.send(());
+ validation
+}
+
+fn write_direct_browser_acceptance_summary(
+ root: &Path,
+ evidence: &BrowserValidationResult,
+ accepted: bool,
+ attempts: usize,
+) -> Result<(), String> {
+ let evidence_root = evidence
+ .evidence
+ .root
+ .strip_prefix(root)
+ .map_err(|_| "直连 Codex 浏览器证据目录不在当前项目内".to_string())?;
+ let evidence_directory = evidence_root
+ .parent()
+ .filter(|path| !path.as_os_str().is_empty())
+ .ok_or_else(|| "直连 Codex 浏览器证据目录缺少版本目录".to_string())?;
+ let relative_path = evidence_directory
+ .join("acceptance-summary.json")
+ .to_string_lossy()
+ .replace('\\', "/");
+ let viewports = evidence
+ .viewport_results
+ .iter()
+ .map(|viewport| {
+ serde_json::json!({
+ "viewport": match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ },
+ "passed": viewport.passed,
+ "readyState": viewport.ready_state,
+ "nonEmptyCanvasCount": viewport
+ .canvases
+ .iter()
+ .filter(|canvas| canvas.non_empty == Some(true))
+ .count(),
+ "canvasCount": viewport.canvases.len(),
+ "consoleErrorCount": viewport.console_errors.len(),
+ "exceptionCount": viewport.exceptions.len(),
+ "fatalRequestCount": viewport
+ .failed_requests
+ .iter()
+ .filter(|request| request.fatal)
+ .count(),
+ "screenshot": viewport
+ .screenshot_path
+ .strip_prefix(root)
+ .ok()
+ .and_then(|path| path.to_str())
+ .map(|path| path.replace('\\', "/")),
+ })
+ })
+ .collect::>();
+ let diagnostics = evidence
+ .diagnostics
+ .iter()
+ .take(12)
+ .map(|detail| redact_agent_runtime_project_paths(root, detail, 320))
+ .collect::>();
+ let rendered_assets_by_viewport = evidence
+ .viewport_results
+ .iter()
+ .map(|viewport| {
+ serde_json::json!({
+ "viewport": match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ },
+ "assets": direct_rendered_taonier_assets_in_viewport(root, viewport),
+ })
+ })
+ .collect::>();
+ let source_report = evidence
+ .evidence
+ .report_path
+ .strip_prefix(root)
+ .ok()
+ .and_then(|path| path.to_str())
+ .map(|path| path.replace('\\', "/"));
+ let summary = serde_json::json!({
+ "schemaVersion": "direct-codex-browser-acceptance.v1",
+ "accepted": accepted,
+ "attempts": attempts,
+ "browserPassed": evidence.passed,
+ "viewports": viewports,
+ "diagnostics": diagnostics,
+ "renderedTaonierAssetsByViewport": rendered_assets_by_viewport,
+ "sourceReport": source_report,
+ });
+ write_agent_runtime_json_sidecar_with_max_bytes(
+ root,
+ &relative_path,
+ "直连 Codex 浏览器验收摘要",
+ &summary,
+ 32 * 1024,
+ )
+}
+
+fn render_direct_browser_acceptance_report(
+ root: &Path,
+ evidence: &BrowserValidationResult,
+) -> String {
+ let referenced_assets = direct_game_sources_referenced_taonier_assets(root);
+ let viewports = evidence
+ .viewport_results
+ .iter()
+ .map(|viewport| {
+ let name = match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ };
+ let non_empty_canvas_count = viewport
+ .canvases
+ .iter()
+ .filter(|canvas| canvas.non_empty == Some(true))
+ .count();
+ format!(
+ "{name}: readyState={},非空 Canvas {non_empty_canvas_count}/{},控制台错误 {},异常 {},致命请求 {}",
+ viewport.ready_state,
+ viewport.canvases.len(),
+ viewport.console_errors.len(),
+ viewport.exceptions.len(),
+ viewport
+ .failed_requests
+ .iter()
+ .filter(|request| request.fatal)
+ .count(),
+ )
+ })
+ .collect::>();
+ let interaction = evidence
+ .viewport_results
+ .iter()
+ .flat_map(|viewport| viewport.diagnostics.iter())
+ .filter(|diagnostic| diagnostic.starts_with("[advisory]"))
+ .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 180))
+ .collect::>();
+ let rendered_assets = evidence
+ .viewport_results
+ .iter()
+ .map(|viewport| {
+ let name = match viewport.viewport {
+ BrowserValidationViewport::Desktop => "desktop",
+ BrowserValidationViewport::Mobile => "mobile",
+ };
+ let assets = direct_rendered_taonier_assets_in_viewport(root, viewport);
+ if assets.is_empty() {
+ format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染")
+ } else {
+ format!("{name}: {}", assets.join("、"))
+ }
+ })
+ .collect::>();
+ format!(
+ "### 客户端真实试玩证据\n\n- 检查文件:`game/index.html`、`game/style.css`、`game/game.js` 及已登记陶泥儿图片。\n- 试玩动作:受限本地预览 + 真实 Chromium desktop/mobile 双视口;截图和完整报告已保存到项目 `.agent/runtime/direct-codex-browser-validation/`。\n- 双视口观察:{}。\n- 平台素材来源:已登记且可解码的陶泥儿图片;游戏源码引用:{}。\n- 平台素材运行时观察:{}。\n- 交互探针:{}。\n- 边界说明:客户端只证明来源、文件和浏览器运行基础;画面质量与玩法判断已由同一 Codex thread 依据上述证据复核。",
+ viewports.join(";"),
+ if referenced_assets.is_empty() {
+ "无(此状态不应被登记为完成)".to_string()
+ } else {
+ referenced_assets.join("、")
+ },
+ rendered_assets.join(";"),
+ if interaction.is_empty() {
+ "未找到可执行的可见控件".to_string()
+ } else {
+ interaction.join(";")
+ },
+ )
+}
+
+fn sync_direct_codex_project_outputs_at(
+ root: &Path,
+ previous_output_fingerprint: Option<&str>,
+) -> Result<(), String> {
+ if !direct_taonier_art_base_is_valid(root) {
+ return Err(
+ "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成"
+ .to_string(),
+ );
+ }
+ if let Some(error) = direct_game_output_completion_error(root) {
+ return Err(error);
+ }
+ let current_output_fingerprint = direct_codex_output_fingerprint(root);
+ let outputs_changed = previous_output_fingerprint
+ .map(|previous| previous != current_output_fingerprint)
+ .unwrap_or(true);
+ let manifest_before = read_manifest(&root.join(".agent/manifest.json"))?;
+ let expected_source = direct_codex_generated_source();
+ let manifest_requires_sync =
+ DIRECT_CODEX_GAME_OUTPUTS
+ .iter()
+ .any(|(local_path, kind, media_type)| {
+ root.join(local_path).is_file()
+ && !manifest_before.assets.iter().any(|asset| {
+ asset.local_path == *local_path
+ && asset.kind == *kind
+ && asset.media_type == *media_type
+ && asset.source == expected_source
+ })
+ })
+ || manifest_before
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed)
+ || manifest_before.versions.is_empty();
+ let mut registered = 0_usize;
+ for (local_path, kind, media_type) in DIRECT_CODEX_GAME_OUTPUTS {
+ if !root.join(local_path).is_file() {
+ continue;
+ }
+ register_local_asset_at(
+ root,
+ local_path,
+ kind,
+ media_type,
+ "direct-codex",
+ direct_codex_generated_source(),
+ )?;
+ registered += 1;
+ }
+ if registered == 0 {
+ return Err("Codex 返回后没有可登记的游戏文件".to_string());
+ }
+ update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?;
+ if outputs_changed || manifest_requires_sync {
+ let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
+ root,
+ "direct-codex.version",
+ )?;
+ let version_revision = advance_agent_runtime_project_revision_locked(root)?;
+ append_agent_game_iteration_version_at(root, version_revision)?;
+ }
+ Ok(())
+}
+
+fn read_context_file_with_limit(root: &Path, relative: &str, max_bytes: usize) -> Option {
+ let path = root.join(relative);
+ let bytes = std::fs::read(path).ok()?;
+ let bounded = &bytes[..bytes.len().min(max_bytes)];
+ let text = sanitize_direct_context(&String::from_utf8_lossy(bounded));
+ (!text.trim().is_empty()).then_some(text)
+}
+
+fn read_context_file(root: &Path, relative: &str) -> Option {
+ read_context_file_with_limit(root, relative, MAX_CONTEXT_FILE_BYTES)
+}
+
+fn bundled_context_text(text: &str) -> String {
+ let bounded = text
+ .chars()
+ .take(MAX_CONTEXT_FILE_BYTES)
+ .collect::();
+ sanitize_direct_context(&bounded)
+}
+
+fn sanitize_direct_context(text: &str) -> String {
+ let mut output = String::new();
+ for line in text.lines() {
+ let lower = line.to_ascii_lowercase();
+ if [
+ "api_key",
+ "apikey",
+ "access_token",
+ "bearer ",
+ "auth.json",
+ ".env",
+ "cookie",
+ "c:\\users\\",
+ "\\users\\",
+ "/users/",
+ ]
+ .iter()
+ .any(|marker| lower.contains(marker))
+ {
+ output.push_str("[已省略疑似凭据或宿主私密路径的内容]\n");
+ continue;
+ }
+ let line = line.chars().take(1_000).collect::();
+ output.push_str(&line);
+ output.push('\n');
+ }
+ output.chars().take(MAX_CONTEXT_FILE_BYTES).collect()
+}
+
+fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> {
+ let directory = root.join(relative_dir);
+ let Ok(entries) = std::fs::read_dir(directory) else {
+ return Vec::new();
+ };
+ let mut paths = entries
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .filter(|path| path.is_file())
+ .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md"))
+ .collect::>();
+ paths.sort();
+ paths
+ .into_iter()
+ .take(MAX_CONTEXT_FILES_PER_SOURCE)
+ .filter_map(|path| {
+ let bytes = std::fs::read(&path).ok()?;
+ let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)];
+ let text = sanitize_direct_context(&String::from_utf8_lossy(bounded));
+ let label = path
+ .strip_prefix(root)
+ .ok()
+ .map(|value| value.to_string_lossy().replace('\\', "/"))?;
+ (!text.trim().is_empty()).then_some((label, text))
+ })
+ .collect()
+}
+
+fn read_skill_files(root: &Path) -> Vec<(String, String)> {
+ let mut skills = Vec::new();
+ for relative_dir in [".codex/skills", ".agents/skills", ".hermes/skills"] {
+ let directory = root.join(relative_dir);
+ let Ok(entries) = std::fs::read_dir(directory) else {
+ continue;
+ };
+ for entry in entries.filter_map(Result::ok) {
+ let skill_path = entry.path().join("SKILL.md");
+ if !skill_path.is_file() {
+ continue;
+ }
+ let Ok(bytes) = std::fs::read(&skill_path) else {
+ continue;
+ };
+ let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)];
+ let text = sanitize_direct_context(&String::from_utf8_lossy(bounded));
+ let Some(label) = skill_path
+ .strip_prefix(root)
+ .ok()
+ .map(|value| value.to_string_lossy().replace('\\', "/"))
+ else {
+ continue;
+ };
+ if !text.trim().is_empty() {
+ skills.push((label, text));
+ }
+ }
+ }
+ skills.sort_by(|left, right| left.0.cmp(&right.0));
+ skills.truncate(MAX_CONTEXT_FILES_PER_SOURCE);
+ skills
+}
+
+pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> String {
+ let mut sections = vec![
+ "你是 Genarrative 的唯一执行智能体。用户聊天内容会直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。你负责理解需求、检查工程、修改工作区、运行必要验证,并用简洁中文报告真实结果。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(),
+ "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
+ "提示词与技能:本次系统上下文只包含当前工作区中有界、已脱敏的 prompts 与 SKILL.md 摘要。技能是执行约束,不是新的 Agent;按需读取技能引用的具体文件,但不要启动 Supervisor、child、harness 或外部编排。".to_string(),
+ format!("--- bundled skill: direct-game-creation ---\n{DIRECT_CODEX_BUILTIN_SKILL}"),
+ DIRECT_TAONIER_ART_WORKFLOW.to_string(),
+ ];
+ sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string());
+ for relative in ["game/index.html", "game/style.css", "game/game.js"] {
+ if let Some(text) =
+ read_context_file_with_limit(root, relative, MAX_DIRECT_PROJECT_FILE_BYTES)
+ {
+ sections.push(format!(
+ "\n--- current project file: {relative} ---\n{text}"
+ ));
+ }
+ }
+ for relative in [
+ "AGENTS.md",
+ "README.md",
+ "CONTEXT.md",
+ "docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md",
+ "docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md",
+ ] {
+ if let Some(text) = read_context_file(root, relative) {
+ sections.push(format!("\n--- {relative} ---\n{text}"));
+ }
+ }
+ for (label, text) in [
+ ("repository/AGENTS.md", BUNDLED_REPOSITORY_AGENTS),
+ (
+ "repository/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md",
+ BUNDLED_AGC_IMPLEMENTATION_PLAN,
+ ),
+ ] {
+ let text = bundled_context_text(text);
+ if !text.trim().is_empty() {
+ sections.push(format!("\n--- bundled context: {label} ---\n{text}"));
+ }
+ }
+ for (label, text) in read_markdown_files(root, ".codex/prompts")
+ .into_iter()
+ .chain(read_markdown_files(root, "prompts"))
+ {
+ sections.push(format!("\n--- prompt: {label} ---\n{text}"));
+ }
+ for (label, text) in read_skill_files(root) {
+ sections.push(format!("\n--- skill: {label} ---\n{text}"));
+ }
+ sections.push("\n工程执行要求:优先复用现有结构;修改后运行与改动相关的测试、typecheck、编码检查和 git diff --check。需要生成游戏时,直接在当前工作区完成代码、资源、运行和验证闭环;不要创建新的 Supervisor/harness。".to_string());
+ sections
+ .join("\n")
+ .chars()
+ .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS)
+ .collect()
+}
+
+pub(crate) async fn run_direct_game_creator_turn_at(
+ root: &Path,
+ prompt: &str,
+) -> Result {
+ if !root.is_absolute() || !root.is_dir() {
+ return Err("当前项目目录不存在或不是绝对路径".to_string());
+ }
+ enforce_project_permission_policy(root, "conversation.read")?;
+ enforce_project_permission_policy(root, "conversation.write")?;
+ let prompt = prompt.trim();
+ if prompt.is_empty() {
+ return Err("聊天内容不能为空".to_string());
+ }
+ emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作");
+ match run_direct_game_creator_turn_inner(root, prompt).await {
+ Ok(reply) => Ok(reply),
+ Err(failure) => Err(record_direct_codex_turn_failure(root, failure)),
+ }
+}
+
+async fn run_direct_game_creator_turn_inner(
+ root: &Path,
+ prompt: &str,
+) -> Result {
+ if direct_existing_game_sources_exist(root)
+ && !direct_prompt_requests_fresh_art_generation(prompt)
+ {
+ emit_direct_game_creator_progress(
+ root,
+ "request.edit-existing-game",
+ "已识别为已有游戏的继续编辑,正在直接修改并试玩",
+ );
+ return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, false)
+ .await;
+ }
+ if direct_taonier_art_base_is_valid(root) {
+ return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true)
+ .await;
+ }
+ let external_editor_credentials = ensure_private_external_editor_api_credentials()
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error)
+ })?;
+ with_external_editor_api_credentials(external_editor_credentials, async {
+ run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true).await
+ })
+ .await
+}
+
+async fn run_direct_game_creator_turn_with_private_editor_credentials(
+ root: &Path,
+ prompt: &str,
+ prepare_art: bool,
+) -> Result {
+ if prepare_art {
+ ensure_direct_taonier_art_package_at(root, prompt)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error)
+ })?;
+ }
+ let previous_output_fingerprint = direct_codex_output_fingerprint(root);
+ let mut system_prompt = build_direct_codex_system_prompt(root);
+ if prepare_art {
+ system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请读取当前 `assets/` 与 `game/` 文件,选择实际存在且适合玩法的素材接入;不要假设四切片一定存在,也不要伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;你必须依据证据继续修复,并在最终回复中给出检查文件、试玩动作、双视口观察、平台素材实际使用、已修复问题和剩余风险。");
+ emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
+ } else {
+ system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 `game/` 和已登记 `assets/`,按用户需求最小修改;随后通过真实 desktop/mobile 浏览器试玩验证修改。客户端会在回合后启动真实浏览器并把结构化证据回灌同一会话。");
+ emit_direct_game_creator_progress(root, "codex.start", "正在修改已有游戏并进行试玩");
+ }
+ let _initial_reply =
+ direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string())
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
+ })?;
+ let initial_output_fingerprint = direct_codex_output_fingerprint(root);
+ let mut completion_error = direct_game_output_completion_error(root);
+ let mut evidence_attempts = 0_usize;
+ let mut evidence: Option = None;
+ let mut browser_checked_output_fingerprint: Option = None;
+ if completion_error.is_none() {
+ emit_direct_game_creator_progress(
+ root,
+ "codex.validate",
+ "游戏代码已生成,正在通过真实浏览器试玩",
+ );
+ evidence_attempts = 1;
+ evidence = Some(
+ run_direct_browser_evidence_at(root, evidence_attempts)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error)
+ })?,
+ );
+ browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone());
+ } else {
+ emit_direct_game_creator_progress(root, "codex.validate", "正在检查游戏入口与平台素材引用");
+ }
+ let repair_prompt = direct_browser_evidence_prompt(
+ root,
+ evidence.as_ref(),
+ 1,
+ initial_output_fingerprint != previous_output_fingerprint,
+ completion_error.as_deref(),
+ );
+ emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏");
+ let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
+ })?;
+ let repaired_fingerprint = direct_codex_output_fingerprint(root);
+ completion_error = direct_game_output_completion_error(root);
+ if completion_error.is_none()
+ && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str())
+ || evidence.as_ref().is_none_or(|result| !result.passed))
+ {
+ evidence_attempts += 1;
+ emit_direct_game_creator_progress(root, "codex.revalidate", "正在复核自主修改后的游戏");
+ evidence = Some(
+ run_direct_browser_evidence_at(root, evidence_attempts)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error)
+ })?,
+ );
+ browser_checked_output_fingerprint = Some(repaired_fingerprint.clone());
+ } else if completion_error.is_some() {
+ evidence = None;
+ }
+ if direct_acceptance_needs_repair(root, evidence.as_ref(), completion_error.as_deref()) {
+ let repair_prompt = direct_browser_evidence_prompt(
+ root,
+ evidence.as_ref(),
+ 2,
+ repaired_fingerprint != previous_output_fingerprint,
+ completion_error.as_deref(),
+ );
+ emit_direct_game_creator_progress(
+ root,
+ "codex.repair",
+ "试玩未通过,正在进行最后一次自主修复",
+ );
+ reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
+ })?;
+ let final_fingerprint = direct_codex_output_fingerprint(root);
+ completion_error = direct_game_output_completion_error(root);
+ if completion_error.is_none()
+ && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str())
+ {
+ evidence_attempts += 1;
+ emit_direct_game_creator_progress(
+ root,
+ "codex.final-validate",
+ "正在进行最终真实浏览器试玩",
+ );
+ evidence = Some(
+ run_direct_browser_evidence_at(root, evidence_attempts)
+ .await
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::BrowserValidation,
+ error,
+ )
+ })?,
+ );
+ } else if completion_error.is_some() {
+ evidence = None;
+ }
+ }
+ if let Some(completion_error) = completion_error {
+ if let Some(evidence) = evidence.as_ref() {
+ write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts)
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error)
+ })?;
+ }
+ return Err(DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::VersionRegistration,
+ format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"),
+ ));
+ }
+ let Some(evidence) = evidence else {
+ return Err(DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::BrowserValidation,
+ "Codex 自主试玩未产生真实浏览器证据,未登记完成版本",
+ ));
+ };
+ if !evidence.passed {
+ write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts)
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error)
+ })?;
+ return Err(DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::BrowserValidation,
+ format!(
+ "Codex 自主试玩未通过,未登记完成版本:{}",
+ evidence
+ .diagnostics
+ .iter()
+ .take(4)
+ .cloned()
+ .collect::>()
+ .join(";")
+ ),
+ ));
+ }
+ if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) {
+ write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts)
+ .map_err(|error| {
+ DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error)
+ })?;
+ return Err(DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::BrowserValidation,
+ "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本"
+ .to_string(),
+ ));
+ }
+ emit_direct_game_creator_progress(
+ root,
+ "codex.ready",
+ "游戏已通过真实浏览器试玩,正在登记项目版本",
+ );
+ sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err(
+ |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error),
+ )?;
+ write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err(
+ |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error),
+ )?;
+ emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览");
+ Ok(format!(
+ "{}\n\n{}",
+ reply.trim(),
+ render_direct_browser_acceptance_report(root, &evidence)
+ ))
+}
+
+#[tauri::command]
+pub(crate) async fn chat_with_game_creator_direct_codex(
+ project_path: String,
+ prompt: String,
+) -> Result {
+ run_direct_game_creator_turn_at(Path::new(project_path.trim()), &prompt).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn system_prompt_is_bounded_and_declares_direct_runtime() {
+ let prompt = build_direct_codex_system_prompt(Path::new("."));
+ assert!(prompt.contains("唯一执行智能体"));
+ assert!(prompt.contains("不要等待 Supervisor"));
+ assert!(prompt.contains("提示词与技能"));
+ }
+
+ #[test]
+ fn system_prompt_loads_project_prompts_and_skills_without_secrets() {
+ let root = tempfile::tempdir().expect("temp dir");
+ let prompt_dir = root.path().join(".codex/prompts");
+ let skill_dir = root.path().join(".codex/skills/demo");
+ std::fs::create_dir_all(&prompt_dir).expect("prompt dir");
+ std::fs::create_dir_all(&skill_dir).expect("skill dir");
+ std::fs::write(
+ prompt_dir.join("game.md"),
+ "做一个 canvas 游戏\napi_key=secret",
+ )
+ .expect("prompt");
+ std::fs::write(
+ skill_dir.join("SKILL.md"),
+ "技能要求:先验证入口\nBearer secret",
+ )
+ .expect("skill");
+ let prompt = build_direct_codex_system_prompt(root.path());
+ assert!(prompt.contains("prompt: .codex/prompts/game.md"));
+ assert!(prompt.contains("skill: .codex/skills/demo/SKILL.md"));
+ assert!(prompt.contains("做一个 canvas 游戏"));
+ assert!(prompt.contains("bundled context: repository/AGENTS.md"));
+ assert!(!prompt.contains("secret"));
+ }
+
+ #[test]
+ fn system_prompt_includes_current_game_files_for_safe_existing_project_edits() {
+ let root = tempfile::tempdir().expect("temp dir");
+ std::fs::create_dir_all(root.path().join("game")).expect("game dir");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "const directLayoutMarker = 'hud-board-gap';\napi_key=secret",
+ )
+ .expect("game script");
+
+ let prompt = build_direct_codex_system_prompt(root.path());
+
+ assert!(prompt.contains("current project file: game/game.js"));
+ assert!(prompt.contains("directLayoutMarker"));
+ assert!(!prompt.contains("api_key=secret"));
+ }
+
+ #[test]
+ fn system_prompt_has_a_total_size_bound() {
+ let root = tempfile::tempdir().expect("temp dir");
+ let prompt_dir = root.path().join("prompts");
+ std::fs::create_dir_all(&prompt_dir).expect("prompt dir");
+ std::fs::write(prompt_dir.join("large.md"), "x".repeat(128 * 1024)).expect("large prompt");
+ let prompt = build_direct_codex_system_prompt(root.path());
+ assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS);
+ }
+
+ #[test]
+ fn direct_prompt_declares_taonier_art_workflow_and_project_structure() {
+ let prompt = build_direct_codex_system_prompt(Path::new("."));
+ assert!(prompt.contains("陶泥儿游戏创作工作流"));
+ assert!(prompt.contains("`assets/`"));
+ assert!(prompt.contains("规范图用于统一视觉风格"));
+ assert!(prompt.contains("完整图集和平台返回的切片可按实际可用性接入"));
+ assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`"));
+ assert!(prompt.contains("最终回复结构化列出检查文件"));
+ assert!(prompt.contains("desktop/mobile 观察"));
+ }
+
+ #[test]
+ fn existing_game_edits_do_not_request_a_fresh_art_generation_by_default() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-edit-intent", "继续编辑")
+ .expect("init project");
+ std::fs::write(root.path().join("game/index.html"), "").expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {};").expect("style");
+ std::fs::write(root.path().join("game/game.js"), "console.log('edit');").expect("script");
+
+ assert!(direct_existing_game_sources_exist(root.path()));
+ for prompt in [
+ "把棋盘上移一点",
+ "修复闪烁",
+ "继续优化交互",
+ "增加一个重开按钮",
+ ] {
+ assert!(
+ !direct_prompt_requests_fresh_art_generation(prompt),
+ "{prompt} must stay in the existing-game edit loop"
+ );
+ }
+ }
+
+ #[test]
+ fn explicit_new_game_or_art_request_enters_fresh_art_generation_path() {
+ for prompt in [
+ "新建一个游戏",
+ "从头做一个三消",
+ "重做美术",
+ "重新生图",
+ "做素材",
+ ] {
+ assert!(
+ direct_prompt_requests_fresh_art_generation(prompt),
+ "{prompt} must explicitly opt into art generation"
+ );
+ }
+ }
+
+ #[test]
+ fn missing_platform_art_reference_becomes_a_same_thread_repair_prompt() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-repair-project", "直连整改")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ std::fs::write(
+ root.path().join("game/index.html"),
+ " ",
+ )
+ .expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {};").expect("style");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "document.querySelector('#game').getContext('2d').fillRect(0, 0, 1, 1);",
+ )
+ .expect("script");
+
+ let completion_error = direct_game_output_completion_error(root.path())
+ .expect("missing platform image reference must require repair");
+ let prompt =
+ direct_browser_evidence_prompt(root.path(), None, 1, true, Some(&completion_error));
+
+ assert!(direct_acceptance_needs_repair(
+ root.path(),
+ None,
+ Some(&completion_error)
+ ));
+ assert!(prompt.contains("同一 Codex 会话"));
+ assert!(prompt.contains("客户端最低完成证明"));
+ assert!(prompt.contains("未在源码中引用任何已登记的陶泥儿平台图片"));
+ assert!(prompt.contains("本次未启动 Chromium"));
+ assert!(prompt.contains("直接修改真实 `game/` 文件"));
+ }
+
+ fn direct_browser_evidence_fixture(
+ root: &Path,
+ viewport_diagnostics: &[(&str, &[&str])],
+ ) -> BrowserValidationResult {
+ let viewport_results = viewport_diagnostics
+ .iter()
+ .map(|(viewport, diagnostics)| BrowserViewportValidationResult {
+ viewport: if *viewport == "mobile" {
+ BrowserValidationViewport::Mobile
+ } else {
+ BrowserValidationViewport::Desktop
+ },
+ width: if *viewport == "mobile" { 390 } else { 1280 },
+ height: if *viewport == "mobile" { 844 } else { 720 },
+ final_url: "http://127.0.0.1:34567/".to_string(),
+ title: "fixture".to_string(),
+ ready_state: "complete".to_string(),
+ visible_text_summary: String::new(),
+ visible_text_character_count: 0,
+ dom_character_count: 0,
+ expected_text: Vec::new(),
+ console_errors: Vec::new(),
+ console_warnings: Vec::new(),
+ exceptions: Vec::new(),
+ failed_requests: Vec::new(),
+ canvases: Vec::new(),
+ blocked_popup_count: 0,
+ blocked_dialog_count: 0,
+ blocked_download_count: 0,
+ blocked_permission_count: 0,
+ blocked_service_worker_count: 0,
+ screenshot_path: root.join(format!("{viewport}.png")),
+ passed: true,
+ diagnostics: diagnostics
+ .iter()
+ .map(|value| (*value).to_string())
+ .collect(),
+ })
+ .collect();
+ BrowserValidationResult {
+ schema_version: "fixture".to_string(),
+ url: "http://127.0.0.1:34567/".to_string(),
+ browser: BrowserIdentity {
+ kind: DiscoveredBrowserKind::Chrome,
+ product: "Chrome/fixture".to_string(),
+ protocol_version: "1.3".to_string(),
+ },
+ passed: true,
+ viewport_results,
+ playtest: None,
+ diagnostics: Vec::new(),
+ evidence: BrowserValidationEvidencePaths {
+ root: root.join(
+ ".agent/runtime/direct-codex-browser-validation/fixture/evidence",
+ ),
+ report_path: root.join(
+ ".agent/runtime/direct-codex-browser-validation/fixture/evidence/validation.json",
+ ),
+ },
+ completed_at_unix_ms: 1,
+ }
+ }
+
+ #[test]
+ fn rendered_taonier_asset_observation_satisfies_both_viewports_and_is_reported() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-rendered-art", "运行时素材观察")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ write_minimal_direct_game_using_spritesheet(root.path());
+ let rendered = format!(
+ "{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png?cache=fixture"
+ );
+ let evidence = direct_browser_evidence_fixture(
+ root.path(),
+ &[
+ ("desktop", &[rendered.as_str()]),
+ ("mobile", &[rendered.as_str()]),
+ ],
+ );
+
+ assert!(!direct_browser_evidence_needs_art_repair(
+ root.path(),
+ Some(&evidence)
+ ));
+ let report = render_direct_browser_acceptance_report(root.path(), &evidence);
+ assert!(report.contains("平台素材运行时观察"), "{report}");
+ assert!(
+ report.contains("desktop: assets/art-spritesheet.png"),
+ "{report}"
+ );
+ assert!(
+ report.contains("mobile: assets/art-spritesheet.png"),
+ "{report}"
+ );
+
+ write_direct_browser_acceptance_summary(root.path(), &evidence, true, 1)
+ .expect("write acceptance summary");
+ let summary = std::fs::read_to_string(root.path().join(
+ ".agent/runtime/direct-codex-browser-validation/fixture/acceptance-summary.json",
+ ))
+ .expect("read acceptance summary");
+ assert!(summary.contains("renderedTaonierAssetsByViewport"));
+ assert_eq!(summary.matches("assets/art-spritesheet.png").count(), 2);
+ }
+
+ #[test]
+ fn source_reference_without_canvas_or_webgl_observation_requests_same_thread_repair() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-side-thumbnail", "旁侧缩略图整改")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ write_minimal_direct_game_using_spritesheet(root.path());
+ let evidence =
+ direct_browser_evidence_fixture(root.path(), &[("desktop", &[]), ("mobile", &[])]);
+
+ assert!(direct_game_output_completion_error(root.path()).is_none());
+ assert!(direct_browser_evidence_needs_art_repair(
+ root.path(),
+ Some(&evidence)
+ ));
+ let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 1, true, None);
+ assert!(
+ prompt.contains("旁侧缩略图不能证明素材进入核心玩法"),
+ "{prompt}"
+ );
+ assert!(
+ prompt.contains("请读取当前工作区的实际 `game/` 文件"),
+ "{prompt}"
+ );
+ }
+
+ #[test]
+ fn one_viewport_without_rendered_platform_art_still_requests_repair() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-mobile-art-gap", "移动端素材整改")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ write_minimal_direct_game_using_spritesheet(root.path());
+ let rendered =
+ format!("{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png");
+ let evidence = direct_browser_evidence_fixture(
+ root.path(),
+ &[("desktop", &[rendered.as_str()]), ("mobile", &[])],
+ );
+
+ assert!(direct_browser_evidence_needs_art_repair(
+ root.path(),
+ Some(&evidence)
+ ));
+ }
+
+ #[test]
+ fn browser_diagnostics_are_redacted_before_same_thread_repair() {
+ let root = tempfile::tempdir().expect("temp dir");
+ let browser_error = format!(
+ "Uncaught ReferenceError at {}?token=secret",
+ root.path().join("game/game.js").display()
+ );
+ let evidence = BrowserValidationResult {
+ schema_version: "fixture".to_string(),
+ url: "http://127.0.0.1:34567/".to_string(),
+ browser: BrowserIdentity {
+ kind: DiscoveredBrowserKind::Chrome,
+ product: "Chrome/fixture".to_string(),
+ protocol_version: "1.3".to_string(),
+ },
+ passed: false,
+ viewport_results: vec![BrowserViewportValidationResult {
+ viewport: BrowserValidationViewport::Desktop,
+ width: 1280,
+ height: 720,
+ final_url: "http://127.0.0.1:34567/".to_string(),
+ title: "fixture".to_string(),
+ ready_state: "complete".to_string(),
+ visible_text_summary: String::new(),
+ visible_text_character_count: 0,
+ dom_character_count: 0,
+ expected_text: Vec::new(),
+ console_errors: Vec::new(),
+ console_warnings: Vec::new(),
+ exceptions: vec![BrowserException {
+ text: browser_error,
+ source_url: None,
+ line_number: 1,
+ column_number: 1,
+ }],
+ failed_requests: Vec::new(),
+ canvases: Vec::new(),
+ blocked_popup_count: 0,
+ blocked_dialog_count: 0,
+ blocked_download_count: 0,
+ blocked_permission_count: 0,
+ blocked_service_worker_count: 0,
+ screenshot_path: root.path().join("desktop.png"),
+ passed: false,
+ diagnostics: vec!["canvas is empty".to_string()],
+ }],
+ playtest: None,
+ diagnostics: Vec::new(),
+ evidence: BrowserValidationEvidencePaths {
+ root: root.path().join("evidence"),
+ report_path: root.path().join("evidence/validation.json"),
+ },
+ completed_at_unix_ms: 1,
+ };
+ let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 2, false, None);
+
+ assert!(prompt.contains("desktop exception:"));
+ assert!(prompt.contains("同一 Codex 会话"));
+ assert!(!prompt.contains("secret"), "{prompt}");
+ assert!(
+ !prompt.contains(&root.path().display().to_string()),
+ "{prompt}"
+ );
+ }
+
+ #[test]
+ fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
+ .expect("init project");
+ let error = record_direct_codex_turn_failure(
+ root.path(),
+ DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::ArtPreparation,
+ "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret",
+ ),
+ );
+
+ assert!(error
+ .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary="));
+ assert!(error.contains(""), "{error}");
+ assert!(error.contains(""), "{error}");
+ assert!(!error.contains("secret"), "{error}");
+ assert!(!error.contains("provider.example"), "{error}");
+
+ let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
+ let entries = std::fs::read_dir(&diagnostics)
+ .expect("diagnostic directory")
+ .filter_map(Result::ok)
+ .collect::>();
+ assert_eq!(entries.len(), 1);
+ let diagnostic_path = entries[0].path().join("failure.json");
+ let diagnostic = std::fs::read_to_string(diagnostic_path).expect("diagnostic sidecar");
+ assert!(diagnostic.contains("\"stage\": \"art-preparation\""));
+ assert!(diagnostic.contains("\"retryable\": true"));
+ assert!(diagnostic.contains(""));
+ assert!(!diagnostic.contains("secret"));
+ assert!(!diagnostic.contains("provider.example"));
+ }
+
+ #[test]
+ fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
+ .expect("init project");
+ let error = record_direct_codex_turn_failure(
+ root.path(),
+ DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::ArtPreparation,
+ "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复",
+ ),
+ );
+
+ assert!(
+ error.contains("stage=art-preparation retryable=false"),
+ "{error}"
+ );
+ assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
+ }
+
+ #[test]
+ fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
+ .expect("init project");
+ let error = record_direct_codex_turn_failure(
+ root.path(),
+ DirectCodexTurnFailure::new(
+ DirectCodexFailureStage::ArtPreparation,
+ "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据",
+ ),
+ );
+
+ assert!(
+ error.contains("summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据"),
+ "{error}"
+ );
+ assert!(error.contains("请检查当前 Windows 用户对本机私有凭据目录的权限后重试"));
+ assert!(!error.contains("[redacted sensitive context]"), "{error}");
+ }
+
+ fn direct_art_spec_identity_fixture() -> DirectTaonierArtAssetIdentity {
+ DirectTaonierArtAssetIdentity {
+ resource_id: "taonier-resource-icon-spec".to_string(),
+ asset_object_id: "taonier-object-icon-spec".to_string(),
+ canvas_project_id: "taonier-project".to_string(),
+ }
+ }
+
+ #[test]
+ fn direct_source_only_spritesheet_recovery_accepts_single_current_spec_reference() {
+ let art_spec = direct_art_spec_identity_fixture();
+ let resources = vec![serde_json::json!({
+ "resourceId": "taonier-resource-spritesheet",
+ "assetKind": "icon-spritesheet",
+ "projectId": "taonier-project",
+ "assetObjectId": "taonier-object-spritesheet",
+ "taskId": "taonier-task-spritesheet",
+ "generationInputs": {
+ "references": [{ "refId": "taonier-resource-icon-spec" }]
+ }
+ })];
+
+ let recovered = direct_recoverable_spritesheet_resource(&resources, &art_spec)
+ .expect("source-only identity must be accepted")
+ .expect("a single matching resource must be returned");
+
+ assert_eq!(
+ json_string_field(recovered, "resourceId").as_deref(),
+ Some("taonier-resource-spritesheet")
+ );
+ }
+
+ #[test]
+ fn direct_source_only_spritesheet_recovery_rejects_multiple_or_foreign_references() {
+ let art_spec = direct_art_spec_identity_fixture();
+ let foreign_or_ambiguous = vec![
+ serde_json::json!({
+ "resourceId": "foreign-reference",
+ "assetKind": "icon-spritesheet",
+ "projectId": "taonier-project",
+ "generationInputs": {
+ "references": [{ "refId": "someone-else" }]
+ }
+ }),
+ serde_json::json!({
+ "resourceId": "multiple-references",
+ "assetKind": "icon-spritesheet",
+ "projectId": "taonier-project",
+ "generationInputs": {
+ "references": [
+ { "refId": "taonier-resource-icon-spec" },
+ { "refId": "another-spec" }
+ ]
+ }
+ }),
+ ];
+
+ assert!(
+ direct_recoverable_spritesheet_resource(&foreign_or_ambiguous, &art_spec)
+ .expect("unrelated entries are not an identity conflict")
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn direct_source_only_spritesheet_recovery_fails_closed_when_multiple_candidates_match() {
+ let art_spec = direct_art_spec_identity_fixture();
+ let candidates = vec![
+ serde_json::json!({
+ "resourceId": "candidate-one",
+ "assetKind": "icon-spritesheet",
+ "projectId": "taonier-project",
+ "generationInputs": {
+ "references": [{ "refId": "taonier-resource-icon-spec" }]
+ }
+ }),
+ serde_json::json!({
+ "resourceId": "candidate-two",
+ "assetKind": "icon-spritesheet",
+ "projectId": "taonier-project",
+ "generationInputs": {
+ "references": [{ "refId": "taonier-resource-icon-spec" }]
+ }
+ }),
+ ];
+
+ let error = direct_recoverable_spritesheet_resource(&candidates, &art_spec)
+ .expect_err("multiple source-only candidates must fail closed");
+ assert!(error.contains("身份不唯一"), "{error}");
+ }
+
+ #[test]
+ fn direct_source_preserved_recovery_is_limited_to_the_canonical_spritesheet() {
+ let error =
+ format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} postprocess failed");
+ assert!(direct_spritesheet_source_preserved_failure(
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ &error,
+ ));
+ assert!(!direct_spritesheet_source_preserved_failure(
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ &error,
+ ));
+ assert!(!direct_spritesheet_source_preserved_failure(
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "ordinary platform failure",
+ ));
+ }
+
+ #[test]
+ fn source_preserved_recovery_clears_the_completed_generation_ledger() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-ledger-cleanup", "源图恢复账本清理")
+ .expect("init project");
+ let pending = setup_platform_art_generation_runtime_accepted_for_recovery_test(
+ root.path(),
+ "art-spritesheet",
+ )
+ .expect("accepted generation ledger");
+ let runtime_context = platform_art_generation_runtime_context_from_pending(&pending);
+ let ledger = root
+ .path()
+ .join(".agent/runtime/canvas-generation-requests/art-director/art-spritesheet.json");
+ assert!(ledger.is_file(), "{pending:?}");
+
+ clear_direct_taonier_generation_ledger_after_source_recovery(root.path(), &runtime_context)
+ .expect("clear completed source-preserved ledger");
+
+ assert!(!ledger.exists());
+ }
+
+ fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) {
+ let (generation_route, generation_kind, reference_resource_ids) = match kind {
+ "art-spritesheet" => (
+ "/api/external/v1/editor/icon-spritesheets/generations",
+ "icon-spritesheet",
+ vec!["taonier-resource-icon-spec".to_string()],
+ ),
+ "game-background" => (
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ vec!["taonier-resource-icon-spec".to_string()],
+ ),
+ _ => (
+ "/api/external/v1/editor/images/generations",
+ "spec",
+ Vec::new(),
+ ),
+ };
+ let mut png_bytes = Vec::new();
+ let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 1,
+ 1,
+ image::Rgba([255, 96, 32, 255]),
+ ));
+ image
+ .write_to(
+ &mut std::io::Cursor::new(&mut png_bytes),
+ image::ImageFormat::Png,
+ )
+ .expect("encode visual asset");
+ std::fs::write(root.join(local_path), &png_bytes).expect("visual asset");
+ register_local_asset_at(
+ root,
+ local_path,
+ kind,
+ "image/png",
+ "platform-art",
+ GameCreationAppAssetSource {
+ kind: GameCreationAppAssetSourceKind::Canvas,
+ canvas_project_id: Some("taonier-project".to_string()),
+ resource_id: Some(format!("taonier-resource-{kind}")),
+ asset_object_id: Some(format!("taonier-object-{kind}")),
+ task_id: Some(format!("taonier-task-{kind}")),
+ prompt: None,
+ model: Some("gpt-image-2".to_string()),
+ generation_route: Some(generation_route.to_string()),
+ generation_kind: Some(generation_kind.to_string()),
+ reference_resource_ids,
+ },
+ )
+ .expect("register TaoNier art asset");
+ }
+
+ fn register_direct_taonier_art_package_fixture(root: &Path) {
+ register_direct_taonier_art_base_fixture(root);
+ register_direct_taonier_art_asset_fixture(
+ root,
+ DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "art-spritesheet",
+ );
+ let slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
+ .iter()
+ .map(|(usage, path)| {
+ let absolute = root.join(path);
+ std::fs::create_dir_all(absolute.parent().expect("slice parent"))
+ .expect("slice directory");
+ std::fs::write(&absolute, tiny_visible_png()).expect("slice png");
+ serde_json::json!({
+ "name": usage,
+ "usage": usage,
+ "path": path,
+ })
+ })
+ .collect::>();
+ let slice_manifest = serde_json::json!({
+ "schemaVersion": "game-art-slices.v1",
+ "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "sourceResourceId": "taonier-resource-art-spritesheet",
+ "sourceAssetObjectId": "taonier-object-art-spritesheet",
+ "sourceCanvasProjectId": "taonier-project",
+ "sourceReferenceResourceIds": ["taonier-resource-icon-spec"],
+ "slices": slices,
+ });
+ std::fs::write(
+ root.join("assets/art-spritesheet-slices/manifest.json"),
+ serde_json::to_vec_pretty(&slice_manifest).expect("slice manifest JSON"),
+ )
+ .expect("slice manifest");
+ std::fs::create_dir_all(root.join(".agent/runtime")).expect("runtime dir");
+ let contract_slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
+ .iter()
+ .map(|(usage, path)| serde_json::json!({ "usage": usage, "path": path }))
+ .collect::>();
+ let receipt = serde_json::json!({
+ "schemaVersion": "game-art-spritesheet-contract.v1",
+ "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
+ "sourceResourceId": "taonier-resource-art-spritesheet",
+ "sourceAssetObjectId": "taonier-object-art-spritesheet",
+ "sourceCanvasProjectId": "taonier-project",
+ "sourceReferenceResourceIds": ["taonier-resource-icon-spec"],
+ "slices": contract_slices,
+ });
+ std::fs::write(
+ root.join(".agent/runtime/art-spritesheet-contract.json"),
+ serde_json::to_vec_pretty(&receipt).expect("receipt JSON"),
+ )
+ .expect("receipt");
+ }
+
+ fn register_direct_taonier_art_base_fixture(root: &Path) {
+ std::fs::create_dir_all(root.join("assets")).expect("assets dir");
+ register_direct_taonier_art_asset_fixture(
+ root,
+ DIRECT_CODEX_ART_SPEC_ASSET_PATH,
+ "icon-spec",
+ );
+ register_direct_taonier_art_asset_fixture(
+ root,
+ DIRECT_CODEX_BACKGROUND_ASSET_PATH,
+ "game-background",
+ );
+ }
+
+ fn tiny_visible_png() -> Vec {
+ let mut bytes = Vec::new();
+ let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 1,
+ 1,
+ image::Rgba([255, 96, 32, 255]),
+ ));
+ image
+ .write_to(
+ &mut std::io::Cursor::new(&mut bytes),
+ image::ImageFormat::Png,
+ )
+ .expect("encode tiny PNG");
+ bytes
+ }
+
+ fn tiny_transparent_png() -> Vec {
+ let mut bytes = Vec::new();
+ let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 1,
+ 1,
+ image::Rgba([255, 96, 32, 0]),
+ ));
+ image
+ .write_to(
+ &mut std::io::Cursor::new(&mut bytes),
+ image::ImageFormat::Png,
+ )
+ .expect("encode transparent PNG");
+ bytes
+ }
+
+ fn write_minimal_direct_game_using_spritesheet(root: &Path) {
+ std::fs::write(
+ root.join("game/index.html"),
+ " ",
+ )
+ .expect("index");
+ std::fs::write(root.join("game/style.css"), "body {};").expect("style");
+ std::fs::write(
+ root.join("game/game.js"),
+ "const art = new Image(); art.src = '/assets/art-spritesheet.png'; document.querySelector('#game').getContext('2d').drawImage(art, 0, 0);",
+ )
+ .expect("script");
+ }
+
+ #[test]
+ fn direct_output_sync_registers_game_files_and_marks_prototype_completed() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ std::fs::write(
+ root.path().join("game/index.html"),
+ " ",
+ )
+ .expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {}").expect("style");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{});",
+ )
+ .expect("script");
+
+ let initial_fingerprint = direct_codex_output_fingerprint(root.path());
+ sync_direct_codex_project_outputs_at(root.path(), Some(&initial_fingerprint))
+ .expect("sync outputs");
+
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert!(manifest.assets.iter().any(|asset| {
+ asset.local_path == "game/index.html" && asset.media_type == "text/html"
+ }));
+ assert!(manifest.assets.iter().any(|asset| {
+ asset.local_path == "game/style.css" && asset.media_type == "text/css"
+ }));
+ assert!(manifest.assets.iter().any(|asset| {
+ asset.local_path == "game/game.js" && asset.media_type == "text/javascript"
+ }));
+ assert_eq!(
+ manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone()),
+ Some(GameCreationAppTaskStatus::Completed)
+ );
+ assert_eq!(manifest.versions.len(), 1);
+ assert_eq!(manifest.versions[0].version_id, "initial-1");
+ assert_eq!(manifest.versions[0].project_revision, 1);
+ assert_eq!(manifest.versions[0].resource_bindings.len(), 6);
+ for expected_path in DIRECT_CODEX_ART_ASSET_PATHS {
+ assert!(manifest.versions[0]
+ .resource_bindings
+ .iter()
+ .any(|binding| {
+ manifest
+ .assets
+ .iter()
+ .find(|asset| asset.id == binding.resource_id)
+ .is_some_and(|asset| asset.local_path == expected_path)
+ }));
+ }
+
+ let first_asset_ids = manifest
+ .assets
+ .iter()
+ .map(|asset| asset.id.clone())
+ .collect::>();
+ let first_output_fingerprint = direct_codex_output_fingerprint(root.path());
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{}); // follow-up",
+ )
+ .expect("follow-up script");
+
+ sync_direct_codex_project_outputs_at(root.path(), Some(&first_output_fingerprint))
+ .expect("sync follow-up outputs");
+
+ let revision = read_game_creator_agent_runtime_project_revision(root.path())
+ .expect("project revision");
+ assert_eq!(revision.revision, 2);
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert_eq!(manifest.assets.len(), 6);
+ assert_eq!(
+ manifest
+ .assets
+ .iter()
+ .map(|asset| asset.id.clone())
+ .collect::>(),
+ first_asset_ids
+ );
+ assert_eq!(manifest.versions.len(), 2);
+ assert_eq!(manifest.versions[1].version_id, "agent-2");
+ assert_eq!(
+ manifest.versions[1].parent_version_id.as_deref(),
+ Some("initial-1")
+ );
+ assert_eq!(manifest.versions[1].project_revision, 2);
+ assert_eq!(
+ manifest.versions[1].created_reason,
+ GameIterationVersionCreatedReason::AgentRevision
+ );
+ assert_eq!(manifest.versions[1].resource_bindings.len(), 6);
+
+ let unchanged_fingerprint = direct_codex_output_fingerprint(root.path());
+ sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint))
+ .expect("sync unchanged outputs");
+ let revision = read_game_creator_agent_runtime_project_revision(root.path())
+ .expect("unchanged project revision");
+ assert_eq!(revision.revision, 2);
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert_eq!(manifest.versions.len(), 2);
+ }
+
+ #[test]
+ fn direct_output_sync_refuses_code_without_a_complete_taonier_art_package() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ std::fs::write(root.path().join("game/index.html"), "").expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {}").expect("style");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "requestAnimationFrame(()=>{});",
+ )
+ .expect("script");
+
+ let error = sync_direct_codex_project_outputs_at(root.path(), None)
+ .expect_err("missing TaoNier art package must block completion");
+ assert!(error.contains("缺少可用且已登记的陶泥儿平台美术资源"));
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert!(manifest
+ .assets
+ .iter()
+ .all(|asset| !asset.local_path.starts_with("game/")));
+ assert_ne!(
+ manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone()),
+ Some(GameCreationAppTaskStatus::Completed)
+ );
+ }
+
+ #[test]
+ fn direct_output_sync_refuses_code_without_any_platform_art_reference() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ std::fs::write(
+ root.path().join("game/index.html"),
+ " ",
+ )
+ .expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {}").expect("style");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "const canvas = document.querySelector('canvas'); canvas.getContext('2d').fillRect(0, 0, 10, 10);",
+ )
+ .expect("script");
+
+ let error = sync_direct_codex_project_outputs_at(root.path(), None)
+ .expect_err("code without a platform art reference must block completion");
+ assert!(
+ error.contains("未在源码中引用任何已登记的陶泥儿平台图片"),
+ "{error}"
+ );
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert!(manifest
+ .assets
+ .iter()
+ .all(|asset| !asset.local_path.starts_with("game/")));
+ }
+
+ #[test]
+ fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ register_direct_taonier_art_base_fixture(root.path());
+ std::fs::write(
+ root.path().join("game/index.html"),
+ " ",
+ )
+ .expect("index");
+ std::fs::write(root.path().join("game/style.css"), "body {}").expect("style");
+ std::fs::write(
+ root.path().join("game/game.js"),
+ "const background = new Image(); background.src = '/assets/direct-game-background.png'; document.querySelector('#game').getContext('2d').drawImage(background, 0, 0); requestAnimationFrame(()=>{});",
+ )
+ .expect("script");
+
+ sync_direct_codex_project_outputs_at(root.path(), None)
+ .expect("trusted TaoNier spec and background must be sufficient for direct completion");
+
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert_eq!(manifest.versions.len(), 1);
+ assert!(manifest
+ .assets
+ .iter()
+ .all(|asset| { asset.local_path != DIRECT_CODEX_SPRITESHEET_ASSET_PATH }));
+ assert_eq!(
+ manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone()),
+ Some(GameCreationAppTaskStatus::Completed)
+ );
+ }
+
+ #[test]
+ fn direct_taonier_art_package_accepts_a_valid_platform_package_without_slices() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS {
+ let _ = std::fs::remove_file(root.path().join(path));
+ }
+ let _ = std::fs::remove_file(
+ root.path()
+ .join("assets/art-spritesheet-slices/manifest.json"),
+ );
+ let _ = std::fs::remove_file(
+ root.path()
+ .join(".agent/runtime/art-spritesheet-contract.json"),
+ );
+
+ assert!(direct_taonier_art_package_is_valid(root.path()));
+ }
+
+ #[test]
+ fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS {
+ let _ = std::fs::remove_file(root.path().join(path));
+ }
+ let _ = std::fs::remove_file(
+ root.path()
+ .join("assets/art-spritesheet-slices/manifest.json"),
+ );
+ let _ = std::fs::remove_file(
+ root.path()
+ .join(".agent/runtime/art-spritesheet-contract.json"),
+ );
+ write_minimal_direct_game_using_spritesheet(root.path());
+
+ sync_direct_codex_project_outputs_at(root.path(), None)
+ .expect("a valid complete spritesheet must be a sufficient direct art path");
+
+ let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
+ assert_eq!(manifest.versions.len(), 1);
+ assert_eq!(
+ manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone()),
+ Some(GameCreationAppTaskStatus::Completed)
+ );
+ }
+
+ #[test]
+ fn direct_taonier_art_package_rejects_a_corrupt_or_transparent_primary_png() {
+ let root = tempfile::tempdir().expect("temp dir");
+ init_local_game_project_at(root.path(), "direct-art-integrity", "直连美术完整性")
+ .expect("init project");
+ register_direct_taonier_art_package_fixture(root.path());
+ let spritesheet = root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH);
+
+ std::fs::write(&spritesheet, b"not-a-png").expect("write corrupt PNG");
+ assert!(
+ !direct_taonier_art_package_is_valid(root.path()),
+ "a corrupt platform PNG must fail closed"
+ );
+
+ std::fs::write(&spritesheet, tiny_transparent_png()).expect("write transparent PNG");
+ assert!(
+ !direct_taonier_art_package_is_valid(root.path()),
+ "a fully transparent platform PNG must fail closed"
+ );
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
index 7d8bb5289..e6d8ca19f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
@@ -24,6 +24,7 @@ pub(crate) use canvas_generation::{
};
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
+ generate_platform_art_asset_with_runtime_options_at,
platform_art_generation_error_result_unknown,
request_platform_art_asset_with_runtime_options_at,
validate_platform_art_png_bytes_with_limits,
@@ -58,10 +59,12 @@ pub(crate) use canvas_generation::request_platform_art_asset_with_options_for_te
#[allow(unused_imports)]
pub(crate) use canvas_generation::{
build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at,
- maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation,
- platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
- prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
- role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
+ generate_platform_art_asset_with_options_at,
+ generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
+ needs_platform_art_asset_generation, platform_art_asset_art_spec,
+ platform_art_asset_output_extension_matches, prepare_platform_art_asset_output_path,
+ project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
+ PlatformArtAssetGenerationOptions,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index 0fe8bab76..0a1c36e55 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -133,6 +133,12 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr
}
pub(crate) fn editor_api_key_is_configured() -> bool {
+ if external_editor_api_credentials_override_is_active() {
+ return true;
+ }
+ if platform_editor_uses_account_routes() {
+ return platform_session_is_available();
+ }
load_game_creator_app_config()
.ok()
.and_then(|config| trim_config_string(&config.editor_api.api_key))
@@ -383,6 +389,19 @@ pub(crate) fn classify_external_generation_initial_response(
match status {
reqwest::StatusCode::OK => {
let generated = external_editor_response_data(payload);
+ if let Some(queue_state) = generated
+ .get("queueState")
+ .filter(|queue_state| queue_state.is_object())
+ {
+ if let Some(operation_id) = json_string_field(queue_state, "operationId") {
+ return Ok(ExternalGenerationInitialResponse::AsyncSubmission(
+ serde_json::json!({
+ "operationId": operation_id,
+ "pollAfterMs": 2_000,
+ }),
+ ));
+ }
+ }
if !external_generation_result_has_download_reference(generated) {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果"
@@ -430,7 +449,8 @@ pub(crate) async fn external_editor_json_request(
.map_err(|error| format!("{action}失败:{error}"))?;
let status = response.status();
if !status.is_success() {
- return Err(format!("{action}失败:HTTP {}", status.as_u16()));
+ let body = response.text().await.unwrap_or_default();
+ return Err(format_external_http_error(action, status, &body));
}
response
.json::()
@@ -438,6 +458,92 @@ pub(crate) async fn external_editor_json_request(
.map_err(|error| format!("解析{action}响应失败:{error}"))
}
+/// Keep provider validation details useful to the operator without copying an
+/// entire response body (which may contain URLs, ids, paths or credentials).
+fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String {
+ let detail = summarize_external_http_error_body(body);
+ match detail {
+ Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()),
+ None => format!("{action}失败:HTTP {}", status.as_u16()),
+ }
+}
+
+fn summarize_external_http_error_body(body: &str) -> Option {
+ let payload = serde_json::from_str::(body).ok()?;
+ let mut fields = Vec::new();
+ collect_external_http_error_fields(&payload, &mut fields);
+ if fields.is_empty() {
+ return None;
+ }
+ let mut detail = fields.join(";");
+ detail = redact_secret_tokens(&detail);
+ detail = redact_absolute_path_tokens(&detail);
+ for field in ["operationId", "operation_id", "api_key", "access_token"] {
+ detail = redact_external_http_error_assignment(&detail, field);
+ }
+ detail.chars().take(600).collect::().into()
+}
+
+fn redact_external_http_error_assignment(value: &str, field: &str) -> String {
+ let marker = format!("{field}=");
+ let mut output = String::with_capacity(value.len());
+ let mut cursor = 0usize;
+ while let Some(offset) = value[cursor..].find(&marker) {
+ let start = cursor + offset;
+ output.push_str(&value[cursor..start]);
+ output.push_str(&marker);
+ output.push_str("[redacted]");
+ let value_start = start + marker.len();
+ let value_end = value[value_start..]
+ .char_indices()
+ .find_map(|(index, character)| {
+ matches!(
+ character,
+ ';' | ';' | ',' | ',' | '&' | '\n' | '\r' | '\t' | ' '
+ )
+ .then_some(value_start + index)
+ })
+ .unwrap_or(value.len());
+ cursor = value_end;
+ }
+ output.push_str(&value[cursor..]);
+ output
+}
+
+fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Vec) {
+ const ALLOWED_KEYS: [&str; 7] = [
+ "code", "field", "message", "reason", "detail", "error", "status",
+ ];
+ match value {
+ serde_json::Value::Object(map) => {
+ for key in ALLOWED_KEYS {
+ if let Some(value) = map.get(key) {
+ match value {
+ serde_json::Value::String(text) if !text.trim().is_empty() => {
+ output.push(format!("{key}={}", text.trim()));
+ }
+ serde_json::Value::Number(number) => {
+ output.push(format!("{key}={number}"));
+ }
+ _ => collect_external_http_error_fields(value, output),
+ }
+ }
+ }
+ for (key, child) in map {
+ if !ALLOWED_KEYS.contains(&key.as_str()) {
+ collect_external_http_error_fields(child, output);
+ }
+ }
+ }
+ serde_json::Value::Array(items) => {
+ for item in items {
+ collect_external_http_error_fields(item, output);
+ }
+ }
+ _ => {}
+ }
+}
+
pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
external_editor_response_data(payload)
.get("pollAfterMs")
@@ -467,9 +573,10 @@ pub(crate) async fn wait_for_external_generation_result(
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
- let operation_id_path =
- url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::();
- let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}");
+ let status_url = format!(
+ "{api_base_url}{}",
+ resolve_platform_generation_status_route(&operation_id)
+ );
let started_at = tokio::time::Instant::now();
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
@@ -504,7 +611,7 @@ pub(crate) async fn wait_for_external_generation_result(
));
}
};
- let generation = external_editor_response_data(&payload);
+ let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("completed") => {
let result = generation
@@ -556,12 +663,32 @@ pub(crate) async fn submit_external_generation_request(
idempotency_key: &str,
request_body_json: &str,
) -> Result {
+ let mut request_body = serde_json::from_str::(request_body_json)
+ .map_err(|error| format!("解析平台图片生成请求失败:{error}"))?;
+ if platform_editor_uses_account_routes() {
+ let inputs = request_body.as_object_mut().and_then(|body| {
+ body.entry("generationInputs")
+ .or_insert_with(|| serde_json::json!({}))
+ .as_object_mut()
+ });
+ if let Some(inputs) = inputs {
+ inputs.insert(
+ "source".to_string(),
+ serde_json::Value::String("ai-game-creator-client".to_string()),
+ );
+ }
+ }
+ let request_body_json = serde_json::to_string(&request_body)
+ .map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
client
- .post(format!("{api_base_url}{endpoint}"))
+ .post(format!(
+ "{api_base_url}{}",
+ resolve_platform_editor_api_route(endpoint)
+ ))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
- .body(request_body_json.to_string())
+ .body(request_body_json)
.send()
.await
.map_err(|error| {
@@ -652,7 +779,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
};
let projects_payload = external_editor_json_request(
client
- .get(format!("{api_base_url}/api/external/v1/editor/projects"))
+ .get(format!(
+ "{api_base_url}{}",
+ resolve_platform_editor_api_route("/api/external/v1/editor/projects")
+ ))
.bearer_auth(api_key),
"读取外部画布项目",
)
@@ -670,7 +800,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
None => {
let payload = external_editor_json_request(
client
- .post(format!("{api_base_url}/api/external/v1/editor/projects"))
+ .post(format!(
+ "{api_base_url}{}",
+ resolve_platform_editor_api_route("/api/external/v1/editor/projects")
+ ))
.bearer_auth(api_key)
.json(&serde_json::json!({ "title": canvas_name })),
"创建外部画布项目",
@@ -686,7 +819,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let library_payload = external_editor_json_request(
client
.get(format!(
- "{api_base_url}/api/external/v1/editor/assets/library"
+ "{api_base_url}{}",
+ resolve_platform_editor_api_route("/api/external/v1/editor/assets/library")
))
.bearer_auth(api_key),
"读取外部素材库",
@@ -707,7 +841,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let payload = external_editor_json_request(
client
.post(format!(
- "{api_base_url}/api/external/v1/editor/assets/folders"
+ "{api_base_url}{}",
+ resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders")
))
.bearer_auth(api_key)
.json(&serde_json::json!({ "label": canvas_name })),
@@ -791,6 +926,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
warning: Option,
slice_warning: Option,
slices: Vec,
+ spritesheet_slice_layout: Option,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec,
@@ -850,7 +986,11 @@ fn canonical_art_spec_reference_at(
}
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec {
- let project_context = truncate_inline(prompt.trim(), 320);
+ // External Editor validates each description independently (currently at
+ // 200 Unicode characters). Keep the gameplay context short enough that a
+ // long creation request cannot reject the atlas before it is queued.
+ const MAX_DESCRIPTION_CHARS: usize = 200;
+ const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
[
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
@@ -858,10 +998,36 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec {
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
]
.into_iter()
- .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}"))
+ .map(|category| {
+ let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
+ category
+ .chars()
+ .count()
+ .saturating_add(CONTEXT_PREFIX.chars().count()),
+ );
+ let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
+ format!("{category}{CONTEXT_PREFIX}{project_context}")
+ })
.collect()
}
+fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
+ let normalized = value.split_whitespace().collect::>().join(" ");
+ let actual_chars = normalized.chars().count();
+ if actual_chars <= max_chars {
+ return normalized;
+ }
+ if max_chars <= 3 {
+ return ".".repeat(max_chars);
+ }
+ let mut output = normalized
+ .chars()
+ .take(max_chars.saturating_sub(3))
+ .collect::();
+ output.push_str("...");
+ output
+}
+
fn decode_platform_art_image_with_limits(
download: &CanvasResourceDownload,
label: &str,
@@ -1099,7 +1265,7 @@ async fn prepare_platform_art_spritesheet_slices(
Ok(prepared)
}
-pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
+pub(crate) async fn generate_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
@@ -1117,6 +1283,85 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(()))
}
+/// Generates the canonical game spritesheet together with the four durable
+/// core slices. Callers that promise a playable game must use this instead
+/// of the permissive asset path: a bare spritesheet is not enough evidence
+/// that player, target, obstacle, and feedback visuals are available.
+pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
+ root: &Path,
+ prompt: &str,
+ briefs: &[AgentGroupBrief],
+ options: &PlatformArtAssetGenerationOptions,
+) -> Result {
+ if options.asset_kind != "art-spritesheet" {
+ return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string());
+ }
+ {
+ let recovery_lock =
+ acquire_project_write_lock(root, "canvas.asset_generate.strict.recover")?;
+ recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?;
+ }
+ let prepared =
+ request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?;
+ let lock = acquire_project_write_lock(root, "canvas.asset_generate.strict")?;
+ recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?;
+ advance_agent_runtime_project_revision_locked(root)?;
+ commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))
+}
+
+/// Generates a platform art asset with a durable request identity owned by a
+/// caller outside the legacy Agent Runtime. Once the platform has accepted the
+/// request, later invocations only recover that same operation; they never
+/// create another billable generation for the same logical asset.
+pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_at(
+ root: &Path,
+ prompt: &str,
+ briefs: &[AgentGroupBrief],
+ options: &PlatformArtAssetGenerationOptions,
+ require_slices: bool,
+ runtime_context: &PlatformArtGenerationRuntimeContext,
+) -> Result {
+ if require_slices && options.asset_kind != "art-spritesheet" {
+ return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string());
+ }
+ {
+ // Direct Codex chat persists the user turn concurrently with the first
+ // platform-art request. Both operations are short-lived project writes;
+ // wait for the active writer instead of failing the whole generation on
+ // the expected startup race.
+ let recovery_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
+ root,
+ "canvas.asset_generate.runtime.recover",
+ )?;
+ recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?;
+ }
+ let prepared = request_platform_art_asset_with_runtime_options_at(
+ root,
+ prompt,
+ briefs,
+ options,
+ Some(runtime_context),
+ )
+ .await?;
+ let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
+ root,
+ "canvas.asset_generate.runtime",
+ )?;
+ recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?;
+ advance_agent_runtime_project_revision_locked(root)?;
+ let generated = if require_slices {
+ commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))?
+ } else {
+ commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(()))?
+ };
+ remove_platform_art_generation_runtime_state_at(
+ root,
+ &runtime_context.agent_id,
+ &runtime_context.run_id,
+ )?;
+ Ok(generated)
+}
+
pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
@@ -1249,7 +1494,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet";
let canonical_reference = matches!(
options.asset_kind.as_str(),
- "ui-prototype" | "art-spritesheet"
+ "ui-prototype" | "game-background" | "art-spritesheet"
)
.then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id))
.transpose()?;
@@ -1262,6 +1507,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
serde_json::json!({
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
+ "sliceLayout": "grid-2x2",
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -1346,6 +1592,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
.await?;
let status = response.status();
if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
if external_generation_submit_rejection_is_definitive(status) {
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
@@ -1354,15 +1601,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
&context.run_id,
)?;
}
- return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16()));
+ return Err(format_external_http_error(
+ "请求平台图片生成",
+ status,
+ &body,
+ ));
}
if runtime_context.is_some() {
return Err(format!(
- "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成提交返回 HTTP {},服务端是否已产生副作用未知",
- status.as_u16()
+ "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {},服务端是否已产生副作用未知",
+ format_external_http_error("平台图片生成提交", status, &body)
));
}
- return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16()));
+ return Err(format_external_http_error(
+ "请求平台图片生成",
+ status,
+ &body,
+ ));
}
let submission_payload = response
.json::()
@@ -1498,6 +1753,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
Vec::new()
};
let warning = platform_art_generation_warning(generated);
+ let spritesheet_slice_layout = if is_canonical_art_spritesheet {
+ json_string_field(generated, "sliceLayout")
+ } else {
+ None
+ };
let resource_id = json_string_field(resource, "resourceId");
let task_id = if is_canonical_art_spritesheet {
consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])?
@@ -1553,6 +1813,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
warning,
slice_warning,
slices,
+ spritesheet_slice_layout,
generation_route,
generation_kind,
reference_resource_ids,
@@ -4869,6 +5130,7 @@ fn validate_strict_platform_art_spritesheet_contract(
task_id: Option<&str>,
generation_route: &str,
generation_kind: &str,
+ spritesheet_slice_layout: Option<&str>,
reference_resource_ids: &[String],
has_transparent_pixels: bool,
has_visible_pixels: bool,
@@ -4905,6 +5167,11 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("game-chat 图集生成 route/kind 与严格图集合同不一致".to_string());
}
+ if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
+ return Err(
+ "game-chat 图集必须由 External Editor 以 grid-2x2 固定切片合同生成".to_string(),
+ );
+ }
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -5248,6 +5515,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
warning,
mut slice_warning,
slices,
+ spritesheet_slice_layout,
generation_route,
generation_kind,
reference_resource_ids,
@@ -5266,6 +5534,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
task_id.as_deref(),
&generation_route,
&generation_kind,
+ spritesheet_slice_layout.as_deref(),
&reference_resource_ids,
spritesheet_has_transparent_pixels,
spritesheet_has_visible_pixels,
@@ -5685,6 +5954,17 @@ pub(crate) fn platform_art_asset_art_spec(
"references": [],
});
}
+ if options.asset_kind == "game-background" {
+ return serde_json::json!({
+ "assetType": "background",
+ "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label),
+ "style": "原创横屏 Web 游戏场景背景;层次清晰,保留可玩区域的视觉留白,不包含 UI 或文字",
+ "composition": "严格 16:9 单场景背景,前景、中景、远景有明确层次;不出现角色、可收集物、操作按钮、HUD、Logo 或文字",
+ "format": format!("{} {}", options.aspect_ratio, options.image_size),
+ "constraints": "必须是可见的真实图片产物,适合作为 Canvas 或 HTML 游戏场景底图;不得做成素材图集、完整游戏截图、海报或概念板;必须原创,不得复刻现有游戏场景、Logo、贴图、标志性布局或受保护视觉语言",
+ "references": [],
+ });
+ }
serde_json::json!({
"assetType": "art",
"subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label),
@@ -5713,6 +5993,12 @@ pub(crate) fn build_platform_art_asset_prompt(
truncate_prompt_context(prompt.trim())
);
}
+ if options.asset_kind == "game-background" {
+ return format!(
+ "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
+ truncate_prompt_context(prompt.trim())
+ );
+ }
let art_asset_brief = briefs
.iter()
.flat_map(|brief| brief.role_briefs.iter())
@@ -5737,6 +6023,28 @@ mod canvas_generation_tests {
ColorType, ImageEncoder,
};
+ #[test]
+ fn external_http_error_summary_keeps_validation_details_without_sensitive_context() {
+ let body = serde_json::json!({
+ "error": {
+ "code": "invalid-request",
+ "field": "sliceLayout",
+ "message": "只支持 grid-2x2;operationId=private-operation-id;api_key=private-key",
+ },
+ "details": {
+ "path": "C:\\Users\\private\\secret.json",
+ }
+ })
+ .to_string();
+ let summary = summarize_external_http_error_body(&body).expect("summary");
+ assert!(summary.contains("code=invalid-request"), "{summary}");
+ assert!(summary.contains("field=sliceLayout"), "{summary}");
+ assert!(summary.contains("只支持 grid-2x2"), "{summary}");
+ assert!(!summary.contains("private-operation-id"), "{summary}");
+ assert!(!summary.contains("private-key"), "{summary}");
+ assert!(!summary.contains("C:\\Users\\private"), "{summary}");
+ }
+
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
@@ -5857,13 +6165,22 @@ mod canvas_generation_tests {
})).collect::>()
});
- let slices = prepare_platform_art_spritesheet_slices(
- &reqwest::Client::new(),
- &base_url,
- "test-api-key",
- &generated,
- 0,
- 0,
+ let slices = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url.clone(),
+ "test-api-key".to_string(),
+ ),
+ async {
+ prepare_platform_art_spritesheet_slices(
+ &reqwest::Client::new(),
+ &base_url,
+ "test-api-key",
+ &generated,
+ 0,
+ 0,
+ )
+ .await
+ },
)
.await
.expect("prepare external icon slices");
@@ -5876,6 +6193,57 @@ mod canvas_generation_tests {
assert!(slices.iter().all(|slice| slice.extension == "png"));
}
+ #[test]
+ fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() {
+ let canvas_context = ExternalCanvasGenerationContext {
+ project_id: "canvas-project".to_string(),
+ asset_folder_id: "asset-folder".to_string(),
+ canvas_name: "contract-test".to_string(),
+ };
+ let slices = (0..4)
+ .map(|index| {
+ let download = rgba_test_png(100 + index);
+ let validated = validate_platform_art_png_bytes_with_limits(
+ &download.bytes,
+ "strict layout receipt test slice",
+ )
+ .expect("fixture slice should validate");
+ PreparedPlatformArtAssetSlice {
+ name: format!("素材 {}", index + 1),
+ width: validated.width,
+ height: validated.height,
+ download,
+ resource_id: Some(format!("slice-resource-{index}")),
+ asset_object_id: Some(format!("slice-object-{index}")),
+ canvas_project_id: Some("canvas-project".to_string()),
+ task_id: Some("spritesheet-task".to_string()),
+ source_resource_id: Some("spritesheet-resource".to_string()),
+ content_sha256: validated.content_sha256,
+ pixel_sha256: validated.pixel_sha256,
+ has_visible_pixels: true,
+ extension: "png".to_string(),
+ }
+ })
+ .collect::>();
+ let error = validate_strict_platform_art_spritesheet_contract(
+ &slices,
+ &canvas_context,
+ Some("canvas-project"),
+ Some("spritesheet-resource"),
+ Some("spritesheet-object"),
+ Some("spritesheet-task"),
+ "/api/external/v1/editor/icon-spritesheets/generations",
+ "icon-spritesheet",
+ None,
+ &["art-spec-resource".to_string()],
+ true,
+ true,
+ )
+ .expect_err("missing fixed-layout proof must not be accepted");
+
+ assert!(error.contains("grid-2x2"));
+ }
+
#[test]
fn canonical_spritesheet_rejects_task_id_conflicts_across_all_identity_copies() {
let generated = serde_json::json!({
@@ -6008,13 +6376,29 @@ mod canvas_generation_tests {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture");
+ listener
+ .set_nonblocking(true)
+ .expect("set prepared recovery fixture nonblocking");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let server_base_url = base_url.clone();
let png = rgba_test_png(u8::MAX).bytes;
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
for request_index in 0..4 {
- let (mut stream, _) = listener.accept().expect("accept prepared recovery request");
+ let deadline = std::time::Instant::now() + Duration::from_secs(5);
+ let (mut stream, _) = loop {
+ match listener.accept() {
+ Ok(accepted) => break accepted,
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "timed out waiting for prepared recovery request {request_index}"
+ );
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ Err(error) => panic!("accept prepared recovery request: {error}"),
+ }
+ };
let request = read_test_http_request(&mut stream);
request_sender
.send(request)
@@ -6075,15 +6459,6 @@ mod canvas_generation_tests {
}
}
});
- let _config_guard = crate::tests::write_test_local_config(
- serde_json::json!({
- "editorApi": {
- "baseUrl": base_url,
- "apiKey": "prepared-recovery-key"
- }
- })
- .to_string(),
- );
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
@@ -6121,27 +6496,36 @@ mod canvas_generation_tests {
.timeout(Duration::from_secs(2))
.build()
.expect("build first submit client");
- let first_error = submit_external_generation_request(
- &first_submit_client,
- &base_url,
- "/api/external/v1/editor/images/generations",
- "prepared-recovery-key",
- &stable_key,
- &stable_body,
+ let (first_error, prepared) = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url.clone(),
+ "prepared-recovery-key".to_string(),
+ ),
+ async {
+ let first_error = submit_external_generation_request(
+ &first_submit_client,
+ &base_url,
+ "/api/external/v1/editor/images/generations",
+ "prepared-recovery-key",
+ &stable_key,
+ &stable_body,
+ )
+ .await
+ .expect_err("first response is intentionally lost");
+ let prepared = request_platform_art_asset_with_runtime_options_at(
+ root,
+ "恢复时不得重建这个提示词",
+ &[],
+ &PlatformArtAssetGenerationOptions::default(),
+ Some(&runtime_context),
+ )
+ .await
+ .expect("resume prepared generation with the durable request");
+ (first_error, prepared)
+ },
)
- .await
- .expect_err("first response is intentionally lost");
+ .await;
assert!(platform_art_generation_error_result_unknown(&first_error));
-
- let prepared = request_platform_art_asset_with_runtime_options_at(
- root,
- "恢复时不得重建这个提示词",
- &[],
- &PlatformArtAssetGenerationOptions::default(),
- Some(&runtime_context),
- )
- .await
- .expect("resume prepared generation with the durable request");
server.join().expect("join prepared recovery fixture");
assert_eq!(
prepared.canvas_context.project_id,
@@ -6236,14 +6620,23 @@ mod canvas_generation_tests {
.timeout(Duration::from_secs(2))
.build()
.expect("build prepared auth client");
- let error = resume_prepared_external_generation_at(
- root,
- &client,
- &client,
- &base_url,
- "prepared-auth-key",
- "/api/external/v1/editor/images/generations",
- state,
+ let error = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url.clone(),
+ "prepared-auth-key".to_string(),
+ ),
+ async {
+ resume_prepared_external_generation_at(
+ root,
+ &client,
+ &client,
+ &base_url,
+ "prepared-auth-key",
+ "/api/external/v1/editor/images/generations",
+ state,
+ )
+ .await
+ },
)
.await
.expect_err("auth rejection cannot prove the original request was not accepted");
@@ -6319,17 +6712,26 @@ mod canvas_generation_tests {
.timeout(Duration::from_secs(2))
.build()
.expect("build polling client");
- let result = wait_for_external_generation_result(
- &client,
- &base_url,
- "test-api-key",
- &serde_json::json!({
- "data": {
- "operationId": "task-async",
- "status": "queued",
- "pollAfterMs": 0
- }
- }),
+ let result = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url.clone(),
+ "test-api-key".to_string(),
+ ),
+ async {
+ wait_for_external_generation_result(
+ &client,
+ &base_url,
+ "test-api-key",
+ &serde_json::json!({
+ "data": {
+ "operationId": "task-async",
+ "status": "queued",
+ "pollAfterMs": 0
+ }
+ }),
+ )
+ .await
+ },
)
.await
.expect("poll completed result");
@@ -6386,25 +6788,29 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture accepted recovery request");
- if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") {
+ if request
+ .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ")
+ {
let body = serde_json::json!({
"data": {
- "operationId": "accepted-operation-1",
- "status": "completed",
- "pollAfterMs": 0,
- "result": {
- "resource": {
- "resourceId": "persisted-resource-1",
- "projectId": "persisted-canvas-project",
- "imageSrc": server_download_url
+ "job": {
+ "operationId": "accepted-operation-1",
+ "status": "completed",
+ "pollAfterMs": 0,
+ "result": {
+ "resource": {
+ "resourceId": "persisted-resource-1",
+ "projectId": "persisted-canvas-project",
+ "imageSrc": server_download_url
+ },
+ "warning": {
+ "code": "unsupported-image-style",
+ "reason": "已保留可用原图"
+ },
+ "sliceWarning": {
+ "reason": "测试切片告警"
+ }
},
- "warning": {
- "code": "unsupported-image-style",
- "reason": "已保留可用原图"
- },
- "sliceWarning": {
- "reason": "测试切片告警"
- }
}
}
})
@@ -6438,14 +6844,10 @@ mod canvas_generation_tests {
.expect("reject unexpected accepted recovery request");
}
});
- let _config_guard = crate::tests::write_test_local_config(
- serde_json::json!({
- "editorApi": {
- "baseUrl": base_url,
- "apiKey": "accepted-recovery-key"
- }
- })
- .to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "accepted-recovery-user",
+ "accepted-recovery-key",
+ &base_url,
);
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
@@ -6530,7 +6932,8 @@ mod canvas_generation_tests {
2,
"accepted recovery must only poll and download"
);
- assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 "));
+ assert!(requests[0]
+ .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 "));
assert!(requests[1].starts_with("GET /download.png "));
assert!(requests.iter().all(|request| !request.starts_with("POST ")));
assert!(requests
@@ -6563,15 +6966,6 @@ mod canvas_generation_tests {
.expect("set configuration drift fixture nonblocking");
let current_base_url =
format!("http://{}", listener.local_addr().expect("fixture address"));
- let _config_guard = crate::tests::write_test_local_config(
- serde_json::json!({
- "editorApi": {
- "baseUrl": current_base_url,
- "apiKey": "current-editor-key"
- }
- })
- .to_string(),
- );
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
@@ -6602,18 +6996,27 @@ mod canvas_generation_tests {
mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0)
.expect("mark stale operation accepted");
- let error = match request_platform_art_asset_with_runtime_options_at(
- root,
- "不得覆盖旧请求",
- &[],
- &PlatformArtAssetGenerationOptions::default(),
- Some(&runtime_context),
+ let error = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ current_base_url,
+ "current-editor-key".to_string(),
+ ),
+ async {
+ match request_platform_art_asset_with_runtime_options_at(
+ root,
+ "不得覆盖旧请求",
+ &[],
+ &PlatformArtAssetGenerationOptions::default(),
+ Some(&runtime_context),
+ )
+ .await
+ {
+ Err(error) => error,
+ Ok(_) => panic!("configuration drift must block GET-only recovery"),
+ }
+ },
)
- .await
- {
- Err(error) => error,
- Ok(_) => panic!("configuration drift must block GET-only recovery"),
- };
+ .await;
assert!(error.contains("服务地址身份"), "{error}");
assert!(matches!(
listener.accept(),
@@ -6643,10 +7046,26 @@ mod canvas_generation_tests {
.expect("allow generation recovery");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted failure fixture");
+ listener
+ .set_nonblocking(true)
+ .expect("set accepted failure fixture nonblocking");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
- let (mut stream, _) = listener.accept().expect("accept failed operation GET");
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ let (mut stream, _) = loop {
+ match listener.accept() {
+ Ok(accepted) => break accepted,
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "timed out waiting for failed operation GET"
+ );
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ Err(error) => panic!("accept failed operation GET: {error}"),
+ }
+ };
let request = read_test_http_request(&mut stream);
request_sender
.send(request)
@@ -6668,12 +7087,6 @@ mod canvas_generation_tests {
.write_all(response.as_bytes())
.expect("write failed operation response");
});
- let _config_guard = crate::tests::write_test_local_config(
- serde_json::json!({
- "editorApi": { "baseUrl": base_url.clone(), "apiKey": "accepted-failure-key" }
- })
- .to_string(),
- );
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
@@ -6706,18 +7119,27 @@ mod canvas_generation_tests {
mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0)
.expect("mark failed operation accepted");
- let error = match request_platform_art_asset_with_runtime_options_at(
- root,
- "不得重新提交",
- &[],
- &PlatformArtAssetGenerationOptions::default(),
- Some(&runtime_context),
+ let error = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url,
+ "accepted-failure-key".to_string(),
+ ),
+ async {
+ match request_platform_art_asset_with_runtime_options_at(
+ root,
+ "不得重新提交",
+ &[],
+ &PlatformArtAssetGenerationOptions::default(),
+ Some(&runtime_context),
+ )
+ .await
+ {
+ Err(error) => error,
+ Ok(_) => panic!("explicit operation failure must be returned"),
+ }
+ },
)
- .await
- {
- Err(error) => error,
- Ok(_) => panic!("explicit operation failure must be returned"),
- };
+ .await;
server.join().expect("join accepted failure fixture");
assert!(error.contains("平台图片生成任务失败"), "{error}");
assert!(request_receiver
@@ -6773,7 +7195,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture accepted scan request");
- if request.starts_with("GET /api/external/v1/generations/test-operation-id ") {
+ if request.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ") {
let body = serde_json::json!({
"data": {
"operationId": "test-operation-id",
@@ -6818,7 +7240,6 @@ mod canvas_generation_tests {
]);
let _config_guard = crate::tests::write_test_local_config(
serde_json::json!({
- "editorApi": { "baseUrl": base_url, "apiKey": "recovery-editor-key" },
"agentLlm": {
"art-director": {
"apiKey": "recovery-llm-key",
@@ -6830,6 +7251,11 @@ mod canvas_generation_tests {
})
.to_string(),
);
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "accepted-recovery-scan-user",
+ "recovery-editor-key",
+ &base_url,
+ );
let run_id = "accepted-recovery-scan-run";
setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id)
.expect("setup accepted generation recovery state");
@@ -6857,7 +7283,7 @@ mod canvas_generation_tests {
stop_sender.send(()).expect("stop accepted scan fixture");
server.join().expect("join accepted scan fixture");
- assert!(first.starts_with("GET /api/external/v1/generations/test-operation-id "));
+ assert!(first.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id "));
assert!(second.starts_with("GET /artifact.png "));
assert!(request_receiver
.recv_timeout(Duration::from_millis(100))
@@ -6927,11 +7353,24 @@ mod canvas_generation_tests {
.timeout(Duration::from_secs(2))
.build()
.expect("build legacy download client");
- let download =
- resolve_canvas_resource_download(&client, &base_url, "test-api-key", &download_source)
+ let download = crate::assets::with_external_editor_api_credentials(
+ crate::assets::external_editor_api_credentials_for_test(
+ base_url.clone(),
+ "test-api-key".to_string(),
+ ),
+ async {
+ resolve_canvas_resource_download(
+ &client,
+ &base_url,
+ "test-api-key",
+ &download_source,
+ )
.await
- .expect("resolve legacy spritesheet download")
- .expect("legacy spritesheet download");
+ },
+ )
+ .await
+ .expect("resolve legacy spritesheet download")
+ .expect("legacy spritesheet download");
assert_eq!(download.bytes, expected_bytes);
assert_eq!(download.media_type, "image/png");
let read_url_request = receiver
@@ -7078,6 +7517,16 @@ mod canvas_generation_tests {
}
}
+ #[test]
+ fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
+ let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
+
+ assert_eq!(descriptions.len(), 4);
+ assert!(descriptions
+ .iter()
+ .all(|description| description.chars().count() <= 200));
+ }
+
#[test]
fn derived_visual_asset_waits_for_registered_art_spec() {
let temporary = tempfile::tempdir().expect("create art dependency project");
@@ -7214,6 +7663,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices: Vec::new(),
+ spritesheet_slice_layout: Some("grid-2x2".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -7529,6 +7979,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices,
+ spritesheet_slice_layout: Some("grid-2x2".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs
index e2d726e00..42c63705f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs
@@ -259,10 +259,11 @@ pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result
pub(crate) fn validate_game_html_smoke(html: &str) -> Result<(), String> {
let lower_html = html.to_ascii_lowercase();
+ validate_closed_game_script_blocks(&lower_html)?;
+ validate_executable_inline_javascript_syntax(html)?;
if !lower_html.contains(" String {
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
- format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
+ let account_identity = platform_session_service_identity().unwrap_or_default();
+ format!(
+ "{:x}",
+ Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes())
+ )
}
pub(crate) fn platform_art_generation_external_service_origin(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs
index 31381a004..5b82b57ad 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs
@@ -7,7 +7,13 @@ pub(crate) async fn request_llm_game_draft_with_client(
short_memory: &str,
long_memory: &str,
) -> Result {
- let llm = GameCreatorLlmConfig::default();
+ // This helper exercises the one-shot OpenAI-compatible response fixture.
+ // Streaming has its own response-stream coverage; keeping this explicit
+ // prevents the production default from making the mock parse JSON as SSE.
+ let llm = GameCreatorLlmConfig {
+ stream: false,
+ ..GameCreatorLlmConfig::default()
+ };
request_generator_game_draft_with_client(
client,
&llm,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs
index 1c3ef53a9..db6594659 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs
@@ -279,21 +279,12 @@ fn agent_interaction_system_prompt(agent_id: &str) -> String {
)
}
-fn build_agent_interaction_request_for_session(
- root: &Path,
+fn build_agent_interaction_llm_request(
agent_id: &str,
- session_id: &str,
prompt: &str,
-) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
- let prompt = prompt.trim();
- if prompt.is_empty() {
- return Err("交互内容不能为空".to_string());
- }
- if !game_creator_agent_uses_interaction_kernel(agent_id) {
- return Err(format!("Agent 不启用交互决策层:{agent_id}"));
- }
- let (llm, config_path, context) =
- build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
+ context: &str,
+ llm: &GameCreatorLlmConfig,
+) -> Result {
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let user_prompt = if context.trim().is_empty() {
format!("用户这轮输入:\n{prompt}")
@@ -311,6 +302,25 @@ fn build_agent_interaction_request_for_session(
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(function_tools)
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
+ apply_game_creator_llm_reasoning_effort(request, llm)
+}
+
+fn build_agent_interaction_request_for_session(
+ root: &Path,
+ agent_id: &str,
+ session_id: &str,
+ prompt: &str,
+) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
+ let prompt = prompt.trim();
+ if prompt.is_empty() {
+ return Err("交互内容不能为空".to_string());
+ }
+ if !game_creator_agent_uses_interaction_kernel(agent_id) {
+ return Err(format!("Agent 不启用交互决策层:{agent_id}"));
+ }
+ let (llm, config_path, context) =
+ build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
+ let request = build_agent_interaction_llm_request(agent_id, prompt, &context, &llm)?;
Ok((llm, config_path, request))
}
@@ -626,12 +636,16 @@ mod tests {
#[test]
fn interaction_request_crosses_neutral_provider_contract_without_shape_drift() {
- let request =
- LlmRunRequest::new(vec![LlmMessage::system("system"), LlmMessage::user("user")])
- .with_api_kind(LlmApiKind::OpenAiResponses)
- .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
- .with_function_tools(agent_interaction_function_tools().expect("interaction tools"))
- .with_tool_choice(platform_llm::LlmToolChoice::Auto);
+ let mut llm = GameCreatorLlmConfig::default();
+ llm.api_kind = "openai_responses".to_string();
+ llm.reasoning_effort = "max".to_string();
+ let request = build_agent_interaction_llm_request(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ "制作一个三消经营游戏",
+ "",
+ &llm,
+ )
+ .expect("build interaction request");
let request = platform_llm::provider_request_from_llm_request("agent-interaction", request)
.expect("neutral request");
assert_eq!(
@@ -639,6 +653,10 @@ mod tests {
Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
);
assert_eq!(request.tools().len(), 3);
+ assert_eq!(
+ request.reasoning_effort(),
+ Some(agent_runtime_core::ProviderReasoningEffort::Max)
+ );
assert!(request.tools().iter().all(|tool| tool.strict()));
assert_eq!(
request.tool_choice(),
@@ -646,6 +664,20 @@ mod tests {
);
}
+ #[test]
+ fn interaction_request_propagates_invalid_reasoning_effort() {
+ let mut llm = GameCreatorLlmConfig::default();
+ llm.reasoning_effort = "maximum".to_string();
+ let error = build_agent_interaction_llm_request(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ "制作一个三消经营游戏",
+ "",
+ &llm,
+ )
+ .expect_err("invalid reasoning effort must fail before Provider request");
+ assert!(error.contains("reasoning_effort"));
+ }
+
#[test]
fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() {
let mut observed = Vec::new();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
index d0082f2f5..2cecd843c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
@@ -285,6 +285,79 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
)?;
return serde_json::to_string(&detail).ok();
}
+ if observation.tool == "command.run_limited" {
+ let summary = observation.summary.trim();
+ if !summary.starts_with("game.static_smoke ") {
+ return None;
+ }
+ let passed = observation.status == "ok" && summary == "game.static_smoke 已完成";
+ let failure_code = if passed {
+ None
+ } else {
+ Some(match summary {
+ "game.static_smoke 执行失败" => "static-smoke-failed",
+ "game.static_smoke 无法取得项目验证锁" => "verification-lock-unavailable",
+ "game.static_smoke 无法清除旧验证凭证" => "verification-reset-failed",
+ "game.static_smoke 结果无法形成有效验证凭证" => {
+ "verification-receipt-invalid"
+ }
+ _ => return None,
+ })
+ };
+ let diagnostic_check = observation.detail.as_deref().map_or("none", |detail| {
+ if detail.contains("JavaScript") {
+ "javascript-syntax"
+ } else {
+ "game-static-contract"
+ }
+ });
+ let persisted = observation
+ .detail
+ .as_deref()
+ .and_then(|value| serde_json::from_str::(value).ok())
+ .filter(|value| {
+ value.get("commandId").and_then(serde_json::Value::as_str)
+ == Some("game.static_smoke")
+ });
+ let diagnostic = if let Some(value) = persisted.as_ref() {
+ if value.get("passed").and_then(serde_json::Value::as_bool) != Some(passed)
+ || value.get("failureCode").and_then(serde_json::Value::as_str) != failure_code
+ {
+ return None;
+ }
+ value
+ .get("diagnostic")
+ .and_then(serde_json::Value::as_str)
+ .map(|value| redact_agent_runtime_error(root, value, 500))
+ } else {
+ observation
+ .detail
+ .as_deref()
+ .map(|value| redact_agent_runtime_error(root, value, 500))
+ };
+ if !passed && diagnostic.is_none() {
+ return None;
+ }
+ if receipt_owner.is_none() {
+ return serde_json::to_string(&serde_json::json!({
+ "commandId": "game.static_smoke",
+ "passed": passed,
+ "failureCode": failure_code,
+ "check": diagnostic_check,
+ "path": AGENT_RUNTIME_GAME_INDEX_PATH,
+ }))
+ .ok();
+ }
+ return serde_json::to_string(&serde_json::json!({
+ "commandId": "game.static_smoke",
+ "passed": passed,
+ "failureCode": failure_code,
+ "check": diagnostic_check,
+ "path": AGENT_RUNTIME_GAME_INDEX_PATH,
+ "diagnostic": diagnostic,
+ }))
+ .ok();
+ }
if observation.tool == "image.inspect" {
let detail = serde_json::from_str::(
observation.detail.as_deref().unwrap_or_default(),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs
index 582e2f20c..dfbd62a90 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs
@@ -84,6 +84,11 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, tool) {
return blocker;
}
+ if let Some(blocker) =
+ agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool)
+ {
+ return blocker;
+ }
if let Some(blocker) = game_chat_delegated_art_agent_input_mutation_block(
root,
agent_id,
@@ -689,3 +694,172 @@ pub(in crate::agent) fn agent_runtime_pending_reconciliation_observation(
detail: Some(redact_agent_runtime_project_paths(root, error, 500)),
}
}
+
+#[cfg(test)]
+mod canvas_only_execution_tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn art_director_canvas_only_execution_rejects_file_write_and_mixed_batch_without_side_effects(
+ ) {
+ const PARENT_RUN_ID: &str = "canvas-only-execution-parent";
+ const CHILD_ID: &str = "art-director";
+ let _config_guard = crate::tests::write_test_local_config(
+ r#"{"editorApi":{"apiKey":"canvas-only-execution-test-key"}}"#.to_string(),
+ );
+ let temporary = tempfile::tempdir().expect("create canvas-only execution root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(
+ &root,
+ "canvas-only-execution-project",
+ "生成带统一视觉规范的完整游戏",
+ )
+ .expect("init canvas-only execution project");
+
+ let parent_session = resolve_agent_conversation_session_id_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ None,
+ true,
+ )
+ .expect("resolve canvas-only parent session");
+ let parent = append_unique_game_creator_agent_runtime_pending_task(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &parent_session,
+ "生成带统一视觉规范的完整游戏",
+ PARENT_RUN_ID,
+ AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ None,
+ )
+ .expect("queue canvas-only parent");
+ let mut parent_state = agent_runtime_state_from_task_record(&parent);
+ parent_state.status = "running".to_string();
+ parent_state.phase = "planning".to_string();
+ append_game_creator_agent_runtime_task(&root, &parent_state)
+ .expect("persist running canvas-only parent");
+
+ let task = format!(
+ "生成统一视觉规范图。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}"
+ );
+ let child_session = resolve_agent_conversation_session_id_at(&root, CHILD_ID, None, true)
+ .expect("resolve canvas-only child session");
+ let child_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, CHILD_ID);
+ let child = append_unique_game_creator_agent_runtime_pending_task(
+ &root,
+ CHILD_ID,
+ &child_session,
+ &task,
+ &child_run_id,
+ "agent-ready-task-scheduler",
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ Some(&AgentRuntimeTaskLink {
+ parent_agent_id: Some(parent.agent_id.clone()),
+ parent_run_id: Some(parent.run_id.clone()),
+ delegation_id: None,
+ }),
+ )
+ .expect("queue canvas-only child");
+ let mut runtime = agent_runtime_state_from_task_record(&child);
+ runtime.status = "running".to_string();
+ runtime.phase = "planning".to_string();
+ write_game_creator_agent_runtime_state(&root, &runtime)
+ .expect("persist canvas-only child runtime");
+ append_game_creator_agent_runtime_task(&root, &runtime)
+ .expect("persist running canvas-only child");
+ update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Running)
+ .expect("mark canvas-only child running");
+
+ let forbidden_path = "game/art-director-forbidden.txt";
+ let forbidden_action = AgentRuntimeToolAction {
+ tool: "file.write".to_string(),
+ reason: Some("尝试写入非 Canvas 文件".to_string()),
+ input: serde_json::json!({
+ "path": forbidden_path,
+ "content": "forbidden"
+ }),
+ };
+ let revision_before = read_game_creator_agent_runtime_project_revision(&root)
+ .expect("read revision before canvas-only rejection");
+ let observation = execute_game_creator_agent_runtime_tool_action(
+ &root,
+ CHILD_ID,
+ &child_run_id,
+ &task,
+ &forbidden_action,
+ )
+ .await;
+ assert_eq!(observation.status, "rejected", "{observation:?}");
+ assert_eq!(
+ observation.summary,
+ "art-director 受控视觉任务拒绝非 Canvas 项目动作"
+ );
+ assert!(!root.join(forbidden_path).exists());
+ assert_eq!(
+ read_game_creator_agent_runtime_project_revision(&root)
+ .expect("read revision after direct canvas-only rejection")
+ .revision,
+ revision_before.revision
+ );
+
+ let mixed_plan = AgentRuntimeToolPlan {
+ thinking_summary: "尝试混合文件写入与 Canvas 生成".to_string(),
+ plan_update: None,
+ plan: vec!["写入文件".to_string(), "生成视觉规范图".to_string()],
+ actions: vec![
+ forbidden_action,
+ AgentRuntimeToolAction {
+ tool: "canvas.asset_generate".to_string(),
+ reason: Some("生成统一视觉规范图".to_string()),
+ input: serde_json::json!({
+ "prompt": "生成统一视觉规范图",
+ "outputPath": AGENT_RUNTIME_ART_SPEC_PATH,
+ "assetKind": "icon-spec",
+ "aspectRatio": "1:1",
+ "imageSize": "1K",
+ "replaceExisting": false
+ }),
+ },
+ ],
+ response: String::new(),
+ };
+ let repository_fingerprint = build_repository_startup_context_at(&root)
+ .expect("build canvas-only repository context")
+ .fingerprint;
+ let prepared = prepare_game_creator_agent_runtime_provider_action_batch(
+ &root,
+ &runtime,
+ &task,
+ &mixed_plan,
+ &[],
+ &revision_before,
+ &repository_fingerprint,
+ )
+ .await
+ .expect("prepare mixed canvas-only provider batch");
+ assert!(
+ matches!(
+ &prepared,
+ AgentRuntimeProviderActionBatchPreparation::Aborted {
+ observation: AgentRuntimeToolObservation {
+ status,
+ summary,
+ ..
+ },
+ ..
+ } if status == "blocked"
+ && summary == "art-director 受控视觉任务拒绝非 Canvas 项目动作"
+ ),
+ "mixed file.write + canvas batch must abort before execution: {prepared:?}"
+ );
+ assert!(!root.join(forbidden_path).exists());
+ assert!(!root.join(AGENT_RUNTIME_ART_SPEC_PATH).exists());
+ assert_eq!(
+ read_game_creator_agent_runtime_project_revision(&root)
+ .expect("read revision after mixed canvas-only rejection")
+ .revision,
+ revision_before.revision
+ );
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs
index 39cde5bbd..55c1e2a64 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs
@@ -273,6 +273,8 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX:
"自主构建固定 owner 的 Runtime 内部产物验证身份不可用";
pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str =
"自主构建只读专业 Agent 禁止执行写入或副作用动作";
+pub(in crate::agent) const AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER: &str =
+ "唯一允许的项目写动作是 canvas.asset_generate";
pub(in crate::agent) fn agent_runtime_autonomous_uses_owner_artifact_validation(
agent_id: &str,
@@ -495,6 +497,49 @@ fn is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(
&& action.input.get("delegation_id").is_none_or(serde_json::Value::is_null))
}
+fn is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(
+ plan: &AgentRuntimeToolPlan,
+) -> bool {
+ let [task_list, run_status] = plan.actions.as_slice() else {
+ return false;
+ };
+ let Some(task_list_input) = task_list.input.as_object() else {
+ return false;
+ };
+ let Some(run_status_input) = run_status.input.as_object() else {
+ return false;
+ };
+ plan.response.trim().is_empty()
+ && plan.plan_update.is_none()
+ && plan.plan.is_empty()
+ && task_list.tool.trim() == "task.list"
+ && task_list_input.is_empty()
+ && run_status.tool.trim() == "agent.run_status"
+ && run_status_input.len() == 3
+ && run_status_input
+ .get("agentId")
+ .is_some_and(serde_json::Value::is_null)
+ && run_status_input
+ .get("scope")
+ .and_then(serde_json::Value::as_str)
+ == Some("all")
+ && run_status_input
+ .get("delegationId")
+ .is_some_and(serde_json::Value::is_null)
+}
+
+fn is_agent_runtime_autonomous_supervisor_acceptance_control_plan(
+ plan: &AgentRuntimeToolPlan,
+) -> bool {
+ plan.response.trim().is_empty()
+ && plan.plan_update.is_none()
+ && plan.actions.len() == 1
+ && matches!(
+ plan.actions[0].tool.trim(),
+ "agent.action_history" | "agent.acceptance_update"
+ )
+}
+
fn is_agent_runtime_autonomous_preview_validation_plan(plan: &AgentRuntimeToolPlan) -> bool {
matches!(plan.actions.as_slice(), [action] if action.tool.trim() == "preview.validate")
}
@@ -513,6 +558,35 @@ fn agent_runtime_autonomous_supervisor_plan_prepares_repair(plan: &AgentRuntimeT
})
}
+fn agent_runtime_game_chat_code_safe_default_repair_plan_matches(
+ plan: &AgentRuntimeToolPlan,
+ delivery: &StaticDelegateDeliveryRecord,
+) -> bool {
+ let [action] = plan.actions.as_slice() else {
+ return false;
+ };
+ plan.response.trim().is_empty()
+ && plan.plan_update.is_none()
+ && plan.plan.is_empty()
+ && action.tool.trim() == "agent.delegate"
+ && agent_runtime_tool_input_text(&action.input, &["agentId", "agent_id"])
+ == delivery.target_agent_id
+ && agent_runtime_tool_input_text(
+ &action.input,
+ &["repairOfDelegationId", "repair_of_delegation_id"],
+ ) == delivery.delegation_id
+ && agent_runtime_tool_input_text(&action.input, &["runId", "run_id"]).is_empty()
+ && !agent_runtime_tool_input_text(&action.input, &["task"]).is_empty()
+ && agent_runtime_tool_input_string_list(
+ &action.input,
+ &["acceptanceCriteria", "acceptance_criteria", "criteria"],
+ ) == delivery.acceptance_criteria
+ && agent_runtime_tool_input_string_list(
+ &action.input,
+ &["expectedArtifacts", "expected_artifacts", "artifacts"],
+ ) == delivery.expected_artifacts
+}
+
pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at(
root: &Path,
agent_id: &str,
@@ -551,6 +625,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
observations: &[AgentRuntimeToolObservation],
plan: &AgentRuntimeToolPlan,
supervisor_requires_delegated_repair: bool,
+ supervisor_manifest_dag_in_progress_at_request: bool,
) -> Result<(), String> {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& autonomous_manifest_dag_in_progress_at(root)?
@@ -620,6 +695,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
));
}
}
+ validate_game_chat_code_safe_default_repair_liveness_at(root, agent_id, run_id, plan)?;
if autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?
&& !plan.response.trim().is_empty()
@@ -649,19 +725,56 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at(
observations,
plan,
supervisor_requires_delegated_repair,
+ supervisor_manifest_dag_in_progress_at_request,
has_current_playtest_receipt,
)
}
-pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
+pub(in crate::agent) fn validate_game_chat_code_safe_default_repair_liveness_at(
root: &Path,
-) -> Result {
+ agent_id: &str,
+ run_id: &str,
+ plan: &AgentRuntimeToolPlan,
+) -> Result<(), String> {
+ if agent_id != "code-prototype"
+ || !static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id)?
+ {
+ return Ok(());
+ }
+ if let Some(delivery) =
+ game_chat_code_parent_safe_default_repair_delivery_at(root, agent_id, run_id)?
+ {
+ if agent_runtime_game_chat_code_safe_default_repair_plan_matches(plan, &delivery) {
+ return Ok(());
+ }
+ return Err(format!(
+ "game-chat code-prototype 必须立即推进唯一安全默认返工;本轮只能对 delegationId={}、targetAgentId={} 提交同合同 agent.delegate,不得重复 route、读取、查询或请求 Provider 空转",
+ delivery.delegation_id, delivery.target_agent_id
+ ));
+ }
+ Ok(())
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(in crate::agent) enum AutonomousManifestDagState {
+ NotStartedOrStalled,
+ InProgress,
+ Completed,
+ Failed {
+ failed_task_ids: Vec,
+ has_active_children: bool,
+ },
+}
+
+pub(in crate::agent) fn autonomous_manifest_dag_state_at(
+ root: &Path,
+) -> Result {
let manifest = read_manifest_for_project(root)?;
let current_root = current_autonomous_game_build_root_task_at(root)?.ok_or_else(|| {
"无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string()
})?;
if !autonomous_game_build_root_task_is_active(¤t_root) {
- return Ok(false);
+ return Ok(AutonomousManifestDagState::NotStartedOrStalled);
}
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(¤t_root.source)
.into_iter()
@@ -675,15 +788,43 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
let running = seed_tasks
.iter()
.any(|task| task.status == GameCreationAppTaskStatus::Running);
- let failed = seed_tasks
+ let mut failed_task_ids = seed_tasks
.iter()
- .any(|task| task.status == GameCreationAppTaskStatus::Failed);
+ .filter(|task| task.status == GameCreationAppTaskStatus::Failed)
+ .map(|task| task.id.clone())
+ .collect::>();
+ failed_task_ids.sort();
let active_child = autonomous_manifest_parent_has_active_ready_task_at(
root,
¤t_root.run_id,
&seed_task_ids,
)?;
- Ok((running || active_child) && !failed)
+ if !failed_task_ids.is_empty() {
+ return Ok(AutonomousManifestDagState::Failed {
+ failed_task_ids,
+ has_active_children: running || active_child,
+ });
+ }
+ if running || active_child {
+ return Ok(AutonomousManifestDagState::InProgress);
+ }
+ if seed_tasks.len() == seed_task_ids.len()
+ && seed_tasks
+ .iter()
+ .all(|task| task.status == GameCreationAppTaskStatus::Completed)
+ {
+ return Ok(AutonomousManifestDagState::Completed);
+ }
+ Ok(AutonomousManifestDagState::NotStartedOrStalled)
+}
+
+pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
+ root: &Path,
+) -> Result {
+ Ok(matches!(
+ autonomous_manifest_dag_state_at(root)?,
+ AutonomousManifestDagState::InProgress
+ ))
}
fn autonomous_manifest_parent_has_active_ready_task_at(
@@ -734,6 +875,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
observations: &[AgentRuntimeToolObservation],
plan: &AgentRuntimeToolPlan,
supervisor_requires_delegated_repair: bool,
+ supervisor_manifest_dag_in_progress_at_request: bool,
has_current_playtest_receipt: bool,
) -> Result<(), String> {
let has_mutation = plan
@@ -762,9 +904,18 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
observations[index].status == "failed"
&& observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试"
});
+ let has_current_playtest_acceptance_control = agent_id
+ == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && has_current_playtest_receipt
+ && is_agent_runtime_autonomous_supervisor_acceptance_control_plan(plan);
+ let has_request_bound_manifest_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && supervisor_manifest_dag_in_progress_at_request
+ && is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(plan);
// A Supervisor without a mutation must still hit the same first-mutation
- // liveness gate as every other autonomous Agent. A concrete failed
- // playtest is more specific and must reach its repair gate below.
+ // liveness gate as every other autonomous Agent. The only exception is
+ // the single-action evidence/update sequence after the current revision
+ // already owns a durable playtest receipt. A concrete failed playtest is
+ // more specific and must reach its repair gate below.
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT
&& verification_gate.mutation_revision.is_none()
@@ -774,6 +925,8 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
&& plan.response.trim().is_empty()
&& !has_specialist_delegation
&& !latest_playtest_is_failed
+ && !has_current_playtest_acceptance_control
+ && !has_request_bound_manifest_wait
{
return Err(format!(
"{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX};Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转"
@@ -990,6 +1143,86 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
))
}
+fn agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool: &str) -> bool {
+ matches!(
+ tool,
+ "memory.read"
+ | "conversation.read"
+ | "asset.list"
+ | "project.index"
+ | "project.search"
+ | "project.diff"
+ | "git.inspect"
+ | "file.list"
+ | "file.read"
+ | "task.list"
+ | "agent.action_history"
+ | "agent.run_status"
+ | "command.output_read"
+ | "command.poll"
+ | "image.inspect"
+ | "canvas.asset_generate"
+ )
+}
+
+pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_task(
+ agent_id: &str,
+ task: &str,
+) -> bool {
+ agent_id == "art-director"
+ && task.contains(AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER)
+}
+
+pub(in crate::agent) fn validate_agent_runtime_autonomous_art_director_canvas_only_plan(
+ agent_id: &str,
+ task: &str,
+ plan: &AgentRuntimeToolPlan,
+) -> Result<(), String> {
+ if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task) {
+ return Ok(());
+ }
+ if let Some(forbidden) = plan.actions.iter().find(|action| {
+ !agent_runtime_autonomous_art_director_canvas_only_tool_allowed(action.tool.trim())
+ }) {
+ return Err(format!(
+ "autonomous art-director 受控视觉任务拒绝动作 {};只允许读取、image.inspect 与唯一 canvas.asset_generate 素材事务",
+ forbidden.tool.trim()
+ ));
+ }
+ let canvas_count = plan
+ .actions
+ .iter()
+ .filter(|action| action.tool.trim() == "canvas.asset_generate")
+ .count();
+ if canvas_count > 1 {
+ return Err(format!(
+ "autonomous art-director 受控视觉任务单轮包含 {canvas_count} 个 canvas.asset_generate,最多允许 1 个"
+ ));
+ }
+ Ok(())
+}
+
+pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_action_block(
+ agent_id: &str,
+ task: &str,
+ tool: &str,
+) -> Option {
+ if !agent_runtime_autonomous_art_director_canvas_only_task(agent_id, task)
+ || agent_runtime_autonomous_art_director_canvas_only_tool_allowed(tool.trim())
+ {
+ return None;
+ }
+ Some(AgentRuntimeToolObservation {
+ tool: tool.trim().to_string(),
+ status: "rejected".to_string(),
+ summary: "art-director 受控视觉任务拒绝非 Canvas 项目动作".to_string(),
+ detail: Some(format!(
+ "tool={} · allowed=read,image.inspect,canvas.asset_generate",
+ tool.trim()
+ )),
+ })
+}
+
pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery(
root: &Path,
agent_id: &str,
@@ -1996,6 +2229,7 @@ mod tests {
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
failed_playtest_revision: None,
updated_at: 0,
};
@@ -2140,6 +2374,7 @@ mod tests {
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
failed_playtest_revision: None,
updated_at: 0,
};
@@ -2152,6 +2387,7 @@ mod tests {
&route_manifest,
false,
false,
+ false,
)
.is_ok());
for (agent_id, plan) in [
@@ -2167,6 +2403,85 @@ mod tests {
}
}
+ #[test]
+ fn autonomous_art_director_visual_task_allows_one_canvas_and_rejects_other_actions() {
+ let task = format!(
+ "生成视觉规范。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER}"
+ );
+ let action = |tool: &str| AgentRuntimeToolAction {
+ tool: tool.to_string(),
+ reason: None,
+ input: serde_json::json!({}),
+ };
+ let allowed = AgentRuntimeToolPlan {
+ actions: vec![action("asset.list"), action("canvas.asset_generate")],
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(
+ validate_agent_runtime_autonomous_art_director_canvas_only_plan(
+ "art-director",
+ &task,
+ &allowed,
+ )
+ .is_ok()
+ );
+ assert!(
+ agent_runtime_autonomous_art_director_canvas_only_action_block(
+ "art-director",
+ &task,
+ "canvas.asset_generate",
+ )
+ .is_none()
+ );
+
+ for tool in [
+ "file.write",
+ "file.patch",
+ "file.delete",
+ "project.patchset",
+ "project.restore",
+ "command.run_limited",
+ "agent.delegate",
+ ] {
+ let rejected = AgentRuntimeToolPlan {
+ actions: vec![action(tool)],
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(
+ validate_agent_runtime_autonomous_art_director_canvas_only_plan(
+ "art-director",
+ &task,
+ &rejected,
+ )
+ .is_err()
+ );
+ assert!(
+ agent_runtime_autonomous_art_director_canvas_only_action_block(
+ "art-director",
+ &task,
+ tool,
+ )
+ .is_some()
+ );
+ }
+
+ let duplicate_canvas = AgentRuntimeToolPlan {
+ actions: vec![
+ action("canvas.asset_generate"),
+ action("canvas.asset_generate"),
+ ],
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(
+ validate_agent_runtime_autonomous_art_director_canvas_only_plan(
+ "art-director",
+ &task,
+ &duplicate_canvas,
+ )
+ .is_err()
+ );
+ }
+
#[test]
fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() {
let verification_gate = AgentRuntimeVerificationGate {
@@ -2181,6 +2496,7 @@ mod tests {
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
failed_playtest_revision: None,
updated_at: 0,
};
@@ -2197,6 +2513,7 @@ mod tests {
&plan,
false,
false,
+ false,
)
.expect_err("Supervisor without a playtest must not bypass pre-mutation liveness");
assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
@@ -2216,6 +2533,7 @@ mod tests {
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
failed_playtest_revision: None,
updated_at: 0,
};
@@ -2246,8 +2564,190 @@ mod tests {
&plan,
false,
false,
+ false,
)
.expect_err("latest successful playtest must not retain an earlier failure exemption");
assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
}
+
+ #[test]
+ fn autonomous_supervisor_current_playtest_allows_only_acceptance_control_actions() {
+ let verification_gate = AgentRuntimeVerificationGate {
+ schema_version: "test".to_string(),
+ project_id: "test".to_string(),
+ agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ run_id: "supervisor-current-playtest-acceptance".to_string(),
+ requires_verification: false,
+ mutation_revision: None,
+ verified_revision: None,
+ last_mutation_tool: None,
+ last_verification_tool: None,
+ last_verification_status: None,
+ static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
+ failed_playtest_revision: None,
+ updated_at: 0,
+ };
+ let action = |tool: &str| AgentRuntimeToolAction {
+ tool: tool.to_string(),
+ reason: None,
+ input: serde_json::json!({}),
+ };
+
+ for tool in ["agent.action_history", "agent.acceptance_update"] {
+ let plan = AgentRuntimeToolPlan {
+ actions: vec![action(tool)],
+ ..AgentRuntimeToolPlan::default()
+ };
+ validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 11,
+ &verification_gate,
+ &[],
+ &plan,
+ false,
+ false,
+ true,
+ )
+ .unwrap_or_else(|error| {
+ panic!("{tool} should remain available for final acceptance: {error}")
+ });
+ }
+
+ let mixed_read = AgentRuntimeToolPlan {
+ actions: vec![action("agent.action_history"), action("file.read")],
+ ..AgentRuntimeToolPlan::default()
+ };
+ let error = validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 11,
+ &verification_gate,
+ &[],
+ &mixed_read,
+ false,
+ false,
+ true,
+ )
+ .expect_err("final acceptance exemption must not permit a mixed read batch");
+ assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
+ }
+
+ #[test]
+ fn autonomous_supervisor_accepts_one_request_bound_manifest_wait_when_live_predicate_closes() {
+ let verification_gate = AgentRuntimeVerificationGate {
+ schema_version: "test".to_string(),
+ project_id: "test".to_string(),
+ agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ run_id: "supervisor-stale-manifest-wait".to_string(),
+ requires_verification: false,
+ mutation_revision: None,
+ verified_revision: None,
+ last_mutation_tool: None,
+ last_verification_tool: None,
+ last_verification_status: None,
+ static_smoke_verified_revision: None,
+ static_smoke_verified_game_index_sha256: None,
+ failed_playtest_revision: None,
+ updated_at: 0,
+ };
+ let plan = AgentRuntimeToolPlan {
+ actions: vec![
+ AgentRuntimeToolAction {
+ tool: "task.list".to_string(),
+ reason: None,
+ input: serde_json::json!({}),
+ },
+ AgentRuntimeToolAction {
+ tool: "agent.run_status".to_string(),
+ reason: None,
+ input: serde_json::json!({
+ "agentId": null,
+ "scope": "all",
+ "delegationId": null,
+ }),
+ },
+ ],
+ ..AgentRuntimeToolPlan::default()
+ };
+ assert!(is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(&plan));
+
+ validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 0,
+ &verification_gate,
+ &[],
+ &plan,
+ false,
+ true,
+ false,
+ )
+ .expect("request-bound wait should survive the live predicate closing once");
+
+ let wait_without_request_binding = validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 0,
+ &verification_gate,
+ &[],
+ &plan,
+ false,
+ false,
+ false,
+ )
+ .expect_err("a request without a bound active observation must not keep waiting");
+ assert!(wait_without_request_binding
+ .starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
+
+ for invalid_plan in [
+ AgentRuntimeToolPlan {
+ actions: vec![plan.actions[0].clone()],
+ ..AgentRuntimeToolPlan::default()
+ },
+ AgentRuntimeToolPlan {
+ actions: vec![plan.actions[1].clone(), plan.actions[0].clone()],
+ ..AgentRuntimeToolPlan::default()
+ },
+ AgentRuntimeToolPlan {
+ actions: vec![
+ plan.actions[0].clone(),
+ AgentRuntimeToolAction {
+ input: serde_json::json!({
+ "agentId": null,
+ "scope": "all",
+ "delegationId": null,
+ "extra": true,
+ }),
+ ..plan.actions[1].clone()
+ },
+ ],
+ ..AgentRuntimeToolPlan::default()
+ },
+ ] {
+ let error = validate_agent_runtime_autonomous_plan_liveness(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ 0,
+ &verification_gate,
+ &[],
+ &invalid_plan,
+ false,
+ true,
+ false,
+ )
+ .expect_err("request-bound wait exemption must reject any broader plan");
+ assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
+ }
+ let response_plan = AgentRuntimeToolPlan {
+ response: "已经完成".to_string(),
+ ..plan.clone()
+ };
+ assert!(
+ !is_agent_runtime_autonomous_supervisor_request_bound_manifest_wait_plan(
+ &response_plan
+ )
+ );
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs
index ac9852d4b..be4893639 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs
@@ -157,6 +157,7 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked(
gate.last_verification_tool = None;
gate.last_verification_status = None;
gate.static_smoke_verified_revision = None;
+ gate.static_smoke_verified_game_index_sha256 = None;
gate.failed_playtest_revision = None;
gate.updated_at = now;
if let Err(error) = write_game_creator_agent_runtime_verification_gate(root, &gate) {
@@ -359,8 +360,59 @@ pub(crate) fn finish_agent_runtime_project_verification_locked(
) -> Result<(), String> {
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
let revision_unchanged = current_revision.revision == expected_revision.revision;
- let passed = passed && revision_unchanged;
+ let mut passed = passed && revision_unchanged;
let verification_tool = gate.last_verification_tool.clone();
+ let mut static_smoke_game_index_sha256 = None;
+ let mut static_smoke_credential_error = None;
+ if passed && verification_tool.as_deref() == Some("game.static_smoke") {
+ match read_autonomous_evidence_file_at(
+ root,
+ AGENT_RUNTIME_GAME_INDEX_PATH,
+ "game.static_smoke 游戏入口",
+ AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES,
+ ) {
+ Ok(Some((digest, bytes))) => {
+ let html = match std::str::from_utf8(&bytes) {
+ Ok(html) => html,
+ Err(error) => {
+ passed = false;
+ static_smoke_credential_error = Some(format!(
+ "game.static_smoke 通过后 game/index.html 不是有效 UTF-8:{error}"
+ ));
+ ""
+ }
+ };
+ if passed && !html.contains(" static_smoke_game_index_sha256 = Some(digest.sha256),
+ Err(error) => {
+ passed = false;
+ static_smoke_credential_error = Some(format!(
+ "game.static_smoke 通过后 game/index.html 内容已变化且复核失败:{error}"
+ ));
+ }
+ }
+ }
+ }
+ Ok(None) => {
+ passed = false;
+ static_smoke_credential_error =
+ Some("game.static_smoke 通过后 game/index.html 缺失".to_string());
+ }
+ Err(error) => {
+ passed = false;
+ static_smoke_credential_error = Some(format!(
+ "game.static_smoke 通过后无法绑定 game/index.html 摘要:{error}"
+ ));
+ }
+ }
+ }
gate.verified_revision = passed
.then_some(current_revision.revision)
.filter(|value| *value > 0);
@@ -376,6 +428,8 @@ pub(crate) fn finish_agent_runtime_project_verification_locked(
}
if verification_tool.as_deref() == Some("game.static_smoke") {
gate.static_smoke_verified_revision = passed.then_some(current_revision.revision);
+ gate.static_smoke_verified_game_index_sha256 =
+ passed.then_some(static_smoke_game_index_sha256).flatten();
} else if verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) {
gate.static_smoke_verified_revision = None;
}
@@ -395,6 +449,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked(
expected_revision.revision, current_revision.revision
));
}
+ if let Some(error) = static_smoke_credential_error {
+ return Err(error);
+ }
Ok(())
}
@@ -790,8 +847,17 @@ pub(in crate::agent) fn static_delegate_completion_blocker_at_locked(
agent_id: &str,
run_id: &str,
) -> Option {
- if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
- return None;
+ match static_delegate_parent_can_manage_receipts_at(root, agent_id, run_id) {
+ Ok(true) => {}
+ Ok(false) => return None,
+ Err(error) => {
+ return Some(AgentRuntimeToolObservation {
+ tool: "runtime.delegate_receipts".to_string(),
+ status: "blocked".to_string(),
+ summary: "无法确认当前父 Run 的专业 Agent 委派权限,不能收束当前任务".to_string(),
+ detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
+ });
+ }
}
match static_delegate_completion_barrier_at(root, agent_id, run_id) {
Ok(barrier) if barrier.is_clear() => None,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs
index 2425bb043..23beaedf2 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs
@@ -649,6 +649,13 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit
"当前 Agent 身份不允许执行该原始工具".to_string(),
)
});
+ let art_director_canvas_only_block =
+ agent_runtime_autonomous_art_director_canvas_only_action_block(
+ &runtime.agent_id,
+ task,
+ action.tool.trim(),
+ )
+ .map(|observation| AgentRuntimeToolPolicyBlock::Denied(observation.summary));
let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block(
root,
&runtime.agent_id,
@@ -670,6 +677,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit
None
};
let local_policy_block = identity_block
+ .or(art_director_canvas_only_block)
.or(game_chat_art_scope_block)
.or(isolated_scope_block)
.or_else(|| {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
index c3e008db8..e0728fb76 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
@@ -12,6 +12,63 @@ enum AgentBackgroundContextMode {
FinalReply,
}
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub(in crate::agent) struct AgentRuntimeToolPlanRequestSnapshot {
+ pub(super) supervisor_manifest_dag_in_progress: bool,
+}
+
+fn parse_manifest_seed_task_counts(detail: &str) -> Option<[u64; 6]> {
+ let counts = detail
+ .lines()
+ .find_map(|line| line.strip_prefix("seedTaskCounts: "))?;
+ let mut parsed = std::collections::BTreeMap::new();
+ for field in counts.split_ascii_whitespace() {
+ let (key, value) = field.split_once('=')?;
+ if parsed.insert(key, value.parse::().ok()?).is_some() {
+ return None;
+ }
+ }
+ let completed = *parsed.get("completed")?;
+ let running = *parsed.get("running")?;
+ let pending = *parsed.get("pending")?;
+ let waiting = *parsed.get("waiting")?;
+ let failed = *parsed.get("failed")?;
+ let total = *parsed.get("total")?;
+ if parsed.len() != 6
+ || completed
+ .checked_add(running)?
+ .checked_add(pending)?
+ .checked_add(waiting)?
+ .checked_add(failed)?
+ != total
+ {
+ return None;
+ }
+ Some([completed, running, pending, waiting, failed, total])
+}
+
+fn prompt_observations_report_manifest_dag_in_progress(
+ observations: &[AgentRuntimeToolObservation],
+) -> bool {
+ let Some(observation) = observations
+ .iter()
+ .rfind(|observation| observation.tool == "task.list")
+ else {
+ return false;
+ };
+ if observation.status != "ok" || observation.summary != "已读取 manifest 任务图" {
+ return false;
+ }
+ let Some([_completed, running, _pending, _waiting, _failed, total]) = observation
+ .detail
+ .as_deref()
+ .and_then(parse_manifest_seed_task_counts)
+ else {
+ return false;
+ };
+ total > 0 && running > 0
+}
+
#[cfg(target_os = "linux")]
fn provider_command_exec_contract() -> &'static str {
"command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令固定运行在 bubblewrap workspace-write、network-disabled 沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入"
@@ -105,7 +162,16 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
observations: &[AgentRuntimeToolObservation],
loop_index: usize,
mcp_catalog: &GameCreatorMcpCatalog,
-) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> {
+) -> Result<
+ (
+ GameCreatorLlmConfig,
+ String,
+ LlmRunRequest,
+ String,
+ AgentRuntimeToolPlanRequestSnapshot,
+ ),
+ String,
+> {
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
if planning_agent {
validate_project_planning_child_binding_at(root, agent_id, run_id)?;
@@ -164,6 +230,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
&& autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?;
let autonomous_project_verify_available = !runtime_owner_artifact_validation_available
&& (!autonomous_game_build || agent_runtime_autonomous_project_verify_available(root));
+ let request_snapshot = AgentRuntimeToolPlanRequestSnapshot {
+ supervisor_manifest_dag_in_progress: autonomous_game_build
+ && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && prompt_observations_report_manifest_dag_in_progress(&prompt_observations),
+ };
let mut allowed_tools = tool_policy.allowed_tools.clone();
let mut auto_tools = tool_policy.auto_tools.clone();
let mut confirm_tools = tool_policy.confirm_tools.clone();
@@ -280,7 +351,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
"file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n",
"task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n",
"canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n",
- "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;Project Supervisor 传 delegationId 时读取当前父 run 的未截断权威返工合同。\n",
+ "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n",
"当前请求中的每个 MCP 工具都以单独的动态函数广告;必须从实际广告函数中选择,并严格按该函数的 input schema 提交 arguments.input。server、tool、catalogFingerprint 和 toolFingerprint 由 Runtime 注入,禁止构造目录外包装调用。\n",
"只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。"
),
@@ -390,7 +461,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
.with_tool_choice(platform_llm::LlmToolChoice::Required);
let request =
apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false);
- return Ok((llm, config_path, request, repository_context_fingerprint));
+ return Ok((
+ llm,
+ config_path,
+ request,
+ repository_context_fingerprint,
+ request_snapshot,
+ ));
}
// M1A-4:Supervisor 的 plan 根 run 需要在合成 system prompt 时收窄
// supervisorIntro / $visualContract 两段(见 prompt.rs),其余角色的 prompt
@@ -571,7 +648,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
} else {
apply_game_creator_llm_web_search(request, &llm, true)?
};
- Ok((llm, config_path, request, repository_context_fingerprint))
+ Ok((
+ llm,
+ config_path,
+ request,
+ repository_context_fingerprint,
+ request_snapshot,
+ ))
}
pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
@@ -767,12 +850,13 @@ fn build_game_creator_background_agent_context(
#[cfg(test)]
mod tests {
use crate::agent::{
- cancel_game_creator_agent_runtime_task_at,
+ autonomous_manifest_dag_in_progress_at, cancel_game_creator_agent_runtime_task_at,
persist_game_chat_supervisor_workflow_decision_at,
start_game_creator_supervisor_background_task_for_session_at,
try_acquire_game_creator_agent_runtime_task_lock,
GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST,
};
+ use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus};
use super::{
agent_runtime_root_source_at, append_unique_game_creator_agent_runtime_pending_task,
@@ -788,6 +872,7 @@ mod tests {
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND,
AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
@@ -876,7 +961,7 @@ mod tests {
summary: "结构化计划更新被 Runtime 拒绝".to_string(),
detail: Some("计划状态回退".to_string()),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
@@ -987,7 +1072,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
agent_id,
&state.session_id,
@@ -1064,7 +1149,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
agent_id,
&state.session_id,
@@ -1302,7 +1387,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
@@ -1365,7 +1450,7 @@ mod tests {
tools: Vec::new(),
};
- let (_, _, first, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
@@ -1433,7 +1518,7 @@ mod tests {
},
)
.expect("freeze plan contract");
- let (_, _, later, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
@@ -1522,17 +1607,18 @@ mod tests {
vec!["核对预加载说明".to_string()],
)
.expect("start supervisor runtime state");
- let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- &supervisor_state.session_id,
- &supervisor_state.run_id,
- &supervisor_state.current_task,
- &[],
- 0,
- &catalog,
- )
- .expect("build supervisor planning request");
+ let (_, _, supervisor_request, _, _) =
+ build_game_creator_agent_background_tool_plan_request(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &supervisor_state.session_id,
+ &supervisor_state.run_id,
+ &supervisor_state.current_task,
+ &[],
+ 0,
+ &catalog,
+ )
+ .expect("build supervisor planning request");
let supervisor_system_prompt = &supervisor_request.messages[0].content;
let supervisor_prompt = &supervisor_request.messages[1].content;
let supervisor_identity_contract =
@@ -1677,7 +1763,7 @@ mod tests {
vec!["核对普通说明".to_string()],
)
.expect("start ordinary runtime state");
- let (_, _, ordinary_request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
"code-prototype",
&ordinary_state.session_id,
@@ -1806,7 +1892,7 @@ mod tests {
vec!["核对 MCP 调用协议".to_string()],
)
.expect("start runtime state");
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
"code-prototype",
&state.session_id,
@@ -1881,7 +1967,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, planning_request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
&planning_state.session_id,
@@ -1967,17 +2053,18 @@ mod tests {
vec!["准备委派".to_string()],
)
.expect("start supervisor");
- let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- &supervisor_state.session_id,
- &supervisor_state.run_id,
- &supervisor_state.current_task,
- &[],
- 0,
- &catalog,
- )
- .expect("build supervisor request");
+ let (_, _, supervisor_request, _, _) =
+ build_game_creator_agent_background_tool_plan_request(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &supervisor_state.session_id,
+ &supervisor_state.run_id,
+ &supervisor_state.current_task,
+ &[],
+ 0,
+ &catalog,
+ )
+ .expect("build supervisor request");
assert!(!supervisor_request.messages[0]
.content
.contains(planning_brief_marker));
@@ -2106,7 +2193,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.session_id,
@@ -2128,6 +2215,135 @@ mod tests {
assert!(!user_prompt.contains("Graph reset 才保留"));
}
+ #[test]
+ fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
+ {
+ let temporary =
+ crate::tests::canonical_test_tempdir("provider-running-sibling-after-failure-");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(
+ &root,
+ "running-sibling-after-failure",
+ "失败兄弟任务后的活跃观察绑定测试",
+ )
+ .expect("project init");
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let run_id = "provider-stale-manifest-observation-run";
+ bind_game_creator_agent_runtime_run_profile_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ run_id,
+ AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ None,
+ )
+ .expect("bind autonomous supervisor");
+ let state = start_game_creator_agent_runtime_task_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ "生成一版可玩的游戏",
+ run_id,
+ AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
+ "等待专业任务图完成",
+ vec!["读取专业任务图状态".to_string()],
+ )
+ .expect("start autonomous supervisor");
+ for task_id in [
+ "design-director",
+ "design-foundation",
+ "balance-director",
+ "balance-seed",
+ "art-director",
+ "art-polish",
+ "audio-director",
+ ] {
+ update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed)
+ .expect("complete manifest fixture task");
+ }
+ update_manifest_task_status_at(&root, "art-asset-plan", GameCreationAppTaskStatus::Failed)
+ .expect("fail manifest fixture task");
+ update_manifest_task_status_at(
+ &root,
+ "audio-asset-plan",
+ GameCreationAppTaskStatus::Running,
+ )
+ .expect("keep sibling manifest fixture task running");
+ assert!(
+ !autonomous_manifest_dag_in_progress_at(&root)
+ .expect("read failure-closed live DAG predicate"),
+ "the regression requires a failed task to close the live predicate while a sibling remains running"
+ );
+ let mut observations = vec![
+ AgentRuntimeToolObservation {
+ tool: "task.list".to_string(),
+ status: "ok".to_string(),
+ summary: "已读取 manifest 任务图".to_string(),
+ detail: Some(
+ "readyTaskIds: (none)\nseedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16\ntaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16"
+ .to_string(),
+ ),
+ },
+ AgentRuntimeToolObservation {
+ tool: "agent.run_status".to_string(),
+ status: "ok".to_string(),
+ summary: "已读取 2 个 Agent 状态".to_string(),
+ detail: Some(
+ "agentId: project-supervisor\nstatus: running\n\nagentId: code-prototype\nstatus: running"
+ .to_string(),
+ ),
+ },
+ ];
+ let catalog = GameCreatorMcpCatalog {
+ fingerprint: String::new(),
+ servers: Vec::new(),
+ tools: Vec::new(),
+ };
+
+ let (_, _, request, _, request_snapshot) =
+ build_game_creator_agent_background_tool_plan_request(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &state.session_id,
+ &state.run_id,
+ &state.current_task,
+ &observations,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
+ &catalog,
+ )
+ .expect("build request from the provider-visible running sibling observation");
+
+ assert!(request.messages[1].content.contains(
+ "seedTaskCounts: completed=7 running=1 pending=7 waiting=0 failed=1 total=16"
+ ));
+ assert!(request_snapshot.supervisor_manifest_dag_in_progress);
+
+ observations.push(AgentRuntimeToolObservation {
+ tool: "task.list".to_string(),
+ status: "ok".to_string(),
+ summary: "已读取 manifest 任务图".to_string(),
+ detail: Some(
+ "readyTaskIds: (none)\nseedTaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16\ntaskCounts: completed=15 running=0 pending=0 waiting=0 failed=1 total=16"
+ .to_string(),
+ ),
+ });
+ let (_, _, _, _, settled_request_snapshot) =
+ build_game_creator_agent_background_tool_plan_request(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &state.session_id,
+ &state.run_id,
+ &state.current_task,
+ &observations,
+ AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 2,
+ &catalog,
+ )
+ .expect("build successor request from the terminal provider-visible observation");
+ assert!(
+ !settled_request_snapshot.supervisor_manifest_dag_in_progress,
+ "a successor request must not inherit an older active observation"
+ );
+ }
+
#[test]
fn game_chat_code_prototype_receives_the_persisted_supervisor_workflow_authority() {
let temporary = crate::tests::canonical_test_tempdir("provider-game-chat-authority-");
@@ -2190,7 +2406,7 @@ mod tests {
servers: Vec::new(),
tools: Vec::new(),
};
- let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
+ let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
&root,
"code-prototype",
&code_state.session_id,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs
index 98df90b05..2218aa0dc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs
@@ -216,12 +216,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
} else {
read_game_creator_mcp_catalog_at(root).await?
};
- let mut built_request = {
+ let (mut built_request, mut supervisor_manifest_dag_in_progress_at_request) = {
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.build.tool_plan",
)?;
- build_game_creator_agent_background_tool_plan_request(
+ let live_manifest_dag_in_progress_before = run_profile
+ == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && autonomous_manifest_dag_in_progress_at(root)?;
+ let request = build_game_creator_agent_background_tool_plan_request(
root,
agent_id,
session_id,
@@ -230,7 +234,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
observations,
loop_index,
&mcp_catalog,
- )?
+ )?;
+ let live_manifest_dag_in_progress_after = run_profile
+ == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && autonomous_manifest_dag_in_progress_at(root)?;
+ let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before
+ || live_manifest_dag_in_progress_after
+ || request.4.supervisor_manifest_dag_in_progress;
+ (request, request_bound_manifest_dag_in_progress)
};
let mut estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
let mut compaction = None;
@@ -266,12 +278,19 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
return Ok(RequestedAgentRuntimeToolPlanOutcome::Superseded);
}
}
- built_request = {
+ (
+ built_request,
+ supervisor_manifest_dag_in_progress_at_request,
+ ) = {
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.rebuild.tool_plan",
)?;
- build_game_creator_agent_background_tool_plan_request(
+ let live_manifest_dag_in_progress_before = run_profile
+ == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && autonomous_manifest_dag_in_progress_at(root)?;
+ let request = build_game_creator_agent_background_tool_plan_request(
root,
agent_id,
session_id,
@@ -280,7 +299,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
observations,
loop_index,
&mcp_catalog,
- )?
+ )?;
+ let live_manifest_dag_in_progress_after = run_profile
+ == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
+ && autonomous_manifest_dag_in_progress_at(root)?;
+ let request_bound_manifest_dag_in_progress = live_manifest_dag_in_progress_before
+ || live_manifest_dag_in_progress_after
+ || request.4.supervisor_manifest_dag_in_progress;
+ (request, request_bound_manifest_dag_in_progress)
};
estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
if estimated_input_tokens > built_request.0.auto_compact_token_limit {
@@ -374,7 +401,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
"锁内冻结后的 tool-plan 请求",
)?;
}
- let (llm, config_path, mut request, repository_context_fingerprint) = built_request;
+ let (llm, config_path, mut request, repository_context_fingerprint, _) = built_request;
let auto_compact_token_limit = llm.auto_compact_token_limit;
let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS
@@ -621,6 +648,17 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
error,
)
})?;
+ validate_agent_runtime_autonomous_art_director_canvas_only_plan(
+ agent_id,
+ task,
+ &parsed.plan,
+ )
+ .map_err(|error| {
+ AgentRuntimeToolPlanProtocolError::new(
+ AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
+ error,
+ )
+ })?;
if autonomous_scaffold_repair_active
&& source_payload.max_field_chars
> AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS
@@ -684,6 +722,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
observations,
&parsed.plan,
supervisor_requires_delegated_repair,
+ supervisor_manifest_dag_in_progress_at_request && repair_attempt == 0,
) {
Ok(()) => Ok((parsed, source_payload)),
Err(error) => Err(AgentRuntimeToolPlanProtocolError::new(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs
index 938455dd8..860ec9622 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs
@@ -518,10 +518,6 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
#[test]
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
- assert!(
- !GameCreatorLlmConfig::default().stream,
- "the production default exercises the non-stream final-reply path"
- );
let project = tempfile::tempdir().expect("create non-stream professional reply project");
let root = project.path();
init_local_game_project_at(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs
index 2a150f911..7ac22121c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/run_status_observation.rs
@@ -12,7 +12,7 @@ pub(in crate::agent) fn mark_supervisor_delivery_claims_observed_for_pending_act
observed_delegate_receipt_ids_from_run_status(observation.detail.as_deref())?;
let observed_isolated_groups =
observed_isolated_join_group_ids_from_run_status(observation.detail.as_deref())?;
- if pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
+ if static_delegate_parent_can_manage_receipts_at(root, &pending.agent_id, &pending.run_id)? {
mark_static_delegate_claim_observed_for_receipts_at(
root,
&pending.agent_id,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs
index d938862f3..0617863b5 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs
@@ -120,8 +120,9 @@ pub(crate) fn apply_agent_runtime_plan_update(
update: &AgentRuntimePlanUpdate,
) -> Result {
let update = sanitize_agent_runtime_plan_update(update)?;
+ let had_structured_plan = agent_runtime_has_structured_plan(runtime);
let mut terminal_steps = std::collections::BTreeMap::new();
- if agent_runtime_has_structured_plan(runtime) {
+ if had_structured_plan {
for step in &runtime.plan_steps {
if matches!(
step.status.as_str(),
@@ -155,10 +156,12 @@ pub(crate) fn apply_agent_runtime_plan_update(
.plan_steps
.iter()
.filter(|step| {
- matches!(
- step.status.as_str(),
- AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED
- ) && !incoming_titles.contains(step.title.as_str())
+ had_structured_plan
+ && matches!(
+ step.status.as_str(),
+ AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED
+ )
+ && !incoming_titles.contains(step.title.as_str())
})
.map(|step| (step.title.clone(), step.status.clone()))
.collect::>();
@@ -175,7 +178,7 @@ pub(crate) fn apply_agent_runtime_plan_update(
));
}
- let unchanged = agent_runtime_has_structured_plan(runtime)
+ let unchanged = had_structured_plan
&& runtime.plan_explanation == update.explanation
&& runtime.plan_steps.len() == merged.len()
&& runtime
@@ -190,11 +193,15 @@ pub(crate) fn apply_agent_runtime_plan_update(
}
let now = unix_timestamp();
- let previous_steps = runtime
- .plan_steps
- .iter()
- .map(|step| (step.title.clone(), step.clone()))
- .collect::>();
+ let previous_steps = if had_structured_plan {
+ runtime
+ .plan_steps
+ .iter()
+ .map(|step| (step.title.clone(), step.clone()))
+ .collect::>()
+ } else {
+ std::collections::BTreeMap::new()
+ };
runtime.plan_steps = merged
.into_iter()
.enumerate()
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
index c268911a0..5a8b856a9 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs
@@ -505,7 +505,7 @@ pub(crate) use entrypoints::{
chat_with_game_creator_role_agent_runtime_for_session_at,
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at,
- configure_game_creator_manifest_invalidation_event_sink,
+ configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs
index 16f9e93b2..09659a149 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs
@@ -14,6 +14,20 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
+pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
+ let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
+ return;
+ };
+ let _ = app.emit(
+ "game-creator-agent-progress",
+ GameCreatorAgentProgressEvent {
+ project_path: root.to_string_lossy().into_owned(),
+ stage: stage.to_string(),
+ message: message.to_string(),
+ },
+ );
+}
+
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
index ae8e3fc0d..d922fe718 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
@@ -165,6 +165,22 @@ fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRunt
}
}
+fn game_chat_fast_path_safe_default_repair_plan(
+ delivery: &StaticDelegateDeliveryRecord,
+) -> AgentRuntimeToolPlan {
+ game_chat_fast_path_action(
+ "agent.delegate",
+ serde_json::json!({
+ "agentId": delivery.target_agent_id,
+ "task": "不要追问用户;采用原回执中已授权的安全默认值,严格按原合同完成唯一一次返工。",
+ "acceptanceCriteria": delivery.acceptance_criteria,
+ "expectedArtifacts": delivery.expected_artifacts,
+ "repairOfDelegationId": delivery.delegation_id,
+ "runId": null,
+ }),
+ )
+}
+
fn game_chat_fast_path_fallback_write_plan_for_root(
root: &Path,
task: &str,
@@ -2243,8 +2259,7 @@ fn game_chat_fast_path_current_revision_is_verified(
&runtime.agent_id,
&runtime.run_id,
)?;
- Ok(revision.revision > 0
- && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision))
+ agent_runtime_static_smoke_passed_for_current_entry_at(root, &gate, revision.revision)
}
fn game_chat_fast_path_is_main_runtime(
@@ -2465,6 +2480,50 @@ pub(crate) fn game_chat_fast_path_plan_at(
}
}
"code-prototype" => {
+ if !static_delegate_parent_can_manage_receipts_at(
+ root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ )? {
+ return Err(
+ "game-chat code-prototype 父 Run 身份链不可信,拒绝管理专业美术回执"
+ .to_string(),
+ );
+ }
+ let delegate_barrier =
+ static_delegate_completion_barrier_at(root, &runtime.agent_id, &runtime.run_id)?;
+ if delegate_barrier.ready_unclaimed_count > 0 {
+ return Ok(Some(game_chat_fast_path_action(
+ "agent.run_status",
+ serde_json::json!({
+ "agentId": null,
+ "scope": "self",
+ "delegationId": null,
+ }),
+ )));
+ }
+ if delegate_barrier.unobserved_claim_count > 0 {
+ return Err(
+ "game-chat 专业美术回执 claim 尚未由原 agent.run_status action 完整观察;拒绝发起新的 Provider 请求"
+ .to_string(),
+ );
+ }
+ if let Some(error) = game_chat_code_parent_terminal_delivery_error_at(
+ root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ )? {
+ return Err(error);
+ }
+ if let Some(delivery) = game_chat_code_parent_safe_default_repair_delivery_at(
+ root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ )? {
+ return Ok(Some(game_chat_fast_path_safe_default_repair_plan(
+ &delivery,
+ )));
+ }
if agent_runtime_has_structured_plan(runtime)
&& runtime
.plan_steps
@@ -2489,7 +2548,11 @@ pub(crate) fn game_chat_fast_path_plan_at(
revision.revision,
)?;
let current_revision_static_smoke_passed = owns_current_mutation
- && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision);
+ && agent_runtime_static_smoke_passed_for_current_entry_at(
+ root,
+ &gate,
+ revision.revision,
+ )?;
let current_revision_failed = owns_current_mutation
&& gate.last_verification_status.as_deref()
== Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED);
@@ -2539,8 +2602,11 @@ pub(crate) fn game_chat_fast_path_plan_at(
{
return Ok(None);
}
- if runtime.loop_iteration > 1
- || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS
+ if budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS
+ || (runtime.loop_iteration > 1
+ && current_route.is_some()
+ && game_chat_fast_path_has_visual_asset(root, "art-asset-plan")
+ && game_chat_fast_path_art_slice_paths(root).is_ok())
{
return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at(
root, &budget, task,
@@ -3896,6 +3962,10 @@ mod tests {
gate.last_verification_tool = Some("game.static_smoke".to_string());
gate.static_smoke_verified_revision = Some(1);
+ gate.static_smoke_verified_game_index_sha256 =
+ fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH))
+ .ok()
+ .map(|bytes| format!("{:x}", Sha256::digest(bytes)));
write_game_creator_agent_runtime_verification_gate(&root, &gate)
.expect("write static smoke gate");
assert!(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
index c5dfd1a9c..cacdeb5d2 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
@@ -1202,35 +1202,59 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
&& static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id)
.is_none();
if autonomous_manifest_parent_can_wait {
+ let manifest_state_before_schedule = match autonomous_manifest_dag_state_at(&root) {
+ Ok(value) => value,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("读取自主构建 manifest 等待屏障失败:{error}"),
+ );
+ }
+ };
// 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的
// manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作,
// 父 Run 必须继续等待,不能提前落入 game-chat fixed-graph-stalled。
- let scheduled_ready_tasks =
- if autonomous_registered_derived_visuals_block_manifest_scheduler_at(
+ // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再
+ // 启动新的 pending sibling 并用它遮蔽原始失败。
+ let manifest_scheduler_blocked = matches!(
+ &manifest_state_before_schedule,
+ AutonomousManifestDagState::Completed
+ | AutonomousManifestDagState::Failed { .. }
+ )
+ || autonomous_registered_derived_visuals_block_manifest_scheduler_at(
&root, &runtime,
+ );
+ let scheduled_ready_tasks = if manifest_scheduler_blocked {
+ Vec::new()
+ } else {
+ match schedule_autonomous_game_build_ready_tasks_at(
+ &root,
+ &agent_id,
+ &runtime.run_id,
+ 3,
) {
- Vec::new()
- } else {
- match schedule_autonomous_game_build_ready_tasks_at(
- &root,
- &agent_id,
- &runtime.run_id,
- 3,
- ) {
- Ok(tasks) => tasks,
- Err(error) => {
- return fail_game_creator_agent_background_context_at(
- &root,
- &agent_id,
- &session_id,
- runtime,
- &format!("调度自主构建 manifest 任务失败:{error}"),
- );
- }
+ Ok(tasks) => tasks,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("调度自主构建 manifest 任务失败:{error}"),
+ );
}
- };
- let manifest_in_progress = if scheduled_ready_tasks.is_empty() {
- match autonomous_manifest_dag_in_progress_at(&root) {
+ }
+ };
+ let manifest_state = if matches!(
+ &manifest_state_before_schedule,
+ AutonomousManifestDagState::Failed { .. }
+ ) {
+ manifest_state_before_schedule
+ } else if scheduled_ready_tasks.is_empty() {
+ match autonomous_manifest_dag_state_at(&root) {
Ok(value) => value,
Err(error) => {
return fail_game_creator_agent_background_context_at(
@@ -1243,9 +1267,17 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
}
} else {
- true
+ AutonomousManifestDagState::InProgress
};
- if manifest_in_progress {
+ let manifest_parent_must_wait = matches!(
+ &manifest_state,
+ AutonomousManifestDagState::InProgress
+ | AutonomousManifestDagState::Failed {
+ has_active_children: true,
+ ..
+ }
+ );
+ if manifest_parent_must_wait {
let blocker =
autonomous_game_build_completion_blocker_at_locked(&root, &runtime)
.unwrap_or_else(|| AgentRuntimeToolObservation {
@@ -1274,6 +1306,21 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
return AgentBackgroundTaskOutcome::WaitingForManifestTasks;
}
+ if let AutonomousManifestDagState::Failed {
+ failed_task_ids, ..
+ } = manifest_state
+ {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!(
+ "自主构建 manifest 专业任务失败;failedTaskIds={}",
+ failed_task_ids.join(",")
+ ),
+ );
+ }
}
match prepare_game_chat_single_round_convergence_at(
&root,
@@ -2147,21 +2194,101 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
runtime.next_step =
"调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;不得把用户修订计入 repair_depth".to_string();
} else if user_input_required {
- // The background drain owns the Supervisor execution lane. Persist a
- // receipt wait first, then let task_queue schedule parent-wake after this
- // pass returns and the lane is released. Parent-wake acquires
- // project -> execution and creates the unique clarification pending under
- // both locks; doing that here would invert the M1C-2b planning projection
- // order.
- runtime.phase = "waiting-for-delegate-receipts".to_string();
- runtime.current_action =
- "等待创建 Project Supervisor 用户澄清请求".to_string();
- runtime.waiting_on =
- "释放当前 execution lane 后投影 planning session 与澄清 pending"
- .to_string();
- runtime.next_step =
- "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求"
- .to_string();
+ let parent_task =
+ match read_latest_game_creator_agent_runtime_task_by_run_id(
+ &root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ ) {
+ Ok(Some(task)) => task,
+ Ok(None) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ "game-chat needs-user-input 缺少父任务",
+ );
+ }
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("读取 needs-user-input 父任务失败:{error}"),
+ );
+ }
+ };
+ let game_chat_safe_default_parent =
+ match trusted_game_chat_autonomous_root_parent_at(&root, &parent_task) {
+ Ok(value) => value,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!(
+ "校验 game-chat needs-user-input 父链失败:{error}"
+ ),
+ );
+ }
+ };
+ if game_chat_safe_default_parent {
+ let deliveries = match claimed_static_delegate_deliveries_at(
+ &root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ ) {
+ Ok(deliveries) => deliveries,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("读取 needs-user-input 回执失败:{error}"),
+ );
+ }
+ };
+ let waiting_for_user =
+ match ensure_static_delegate_user_input_wait_at(
+ &root,
+ &mut runtime,
+ &deliveries,
+ ) {
+ Ok(waiting) => waiting,
+ Err(error) => {
+ return fail_game_creator_agent_background_context_at(
+ &root,
+ &agent_id,
+ &session_id,
+ runtime,
+ &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"),
+ );
+ }
+ };
+ if waiting_for_user {
+ return AgentBackgroundTaskOutcome::WaitingForUserInput;
+ }
+ runtime.phase = "planning".to_string();
+ runtime.current_action =
+ "等待 Project Supervisor 采用安全默认值发起唯一返工".to_string();
+ runtime.waiting_on =
+ "已转换为 needs-repair 的自主构建专业回执".to_string();
+ runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;返工任务必须采用安全默认值继续,不能再询问用户".to_string();
+ } else {
+ runtime.phase = "waiting-for-delegate-receipts".to_string();
+ runtime.current_action =
+ "等待创建 Project Supervisor 用户澄清请求".to_string();
+ runtime.waiting_on =
+ "释放当前 execution lane 后投影 planning session 与澄清 pending"
+ .to_string();
+ runtime.next_step =
+ "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求"
+ .to_string();
+ }
} else if repair_required {
runtime.phase = "planning".to_string();
runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
index e0bd9537a..25309df73 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
@@ -202,11 +202,7 @@ fn queue_game_chat_fast_path_child(
)
}
-fn persist_game_chat_main_asset_audit_and_route(
- root: &Path,
- root_state: &AgentRuntimeState,
- main_state: &mut AgentRuntimeState,
-) {
+fn persist_game_chat_main_asset_audit(root: &Path, main_state: &mut AgentRuntimeState) {
assert_eq!(main_state.agent_id, "code-prototype");
main_state
.recent_tool_calls
@@ -225,6 +221,14 @@ fn persist_game_chat_main_asset_audit_and_route(
.expect("persist game-chat main asset audit task");
write_game_creator_agent_runtime_state(root, main_state)
.expect("persist game-chat main asset audit runtime");
+}
+
+fn persist_game_chat_main_asset_audit_and_route(
+ root: &Path,
+ root_state: &AgentRuntimeState,
+ main_state: &mut AgentRuntimeState,
+) {
+ persist_game_chat_main_asset_audit(root, main_state);
persist_game_chat_supervisor_workflow_decision_at(
root,
&root_state.agent_id,
@@ -369,851 +373,47 @@ fn game_chat_main_art_child_fixture(
(main, child, delegation_id)
}
-fn snapshot_agent_durable_files(root: &Path) -> std::collections::BTreeMap> {
- fn visit(
- base: &Path,
- directory: &Path,
- snapshot: &mut std::collections::BTreeMap>,
- ) {
- let Ok(entries) = fs::read_dir(directory) else {
- return;
- };
- for entry in entries {
- let entry = entry.expect("read durable Agent entry");
- let path = entry.path();
- let file_type = entry.file_type().expect("read durable Agent file type");
- if file_type.is_dir() {
- visit(base, &path, snapshot);
- } else if file_type.is_file() {
- if path
- .file_name()
- .and_then(|value| value.to_str())
- .is_some_and(|value| value.ends_with(".lock"))
- {
- continue;
- }
- let relative = path
- .strip_prefix(base)
- .expect("durable Agent file remains below project root")
- .to_string_lossy()
- .replace('\\', "/");
- snapshot.insert(relative, fs::read(&path).expect("read durable Agent file"));
- }
- }
- }
-
- let mut snapshot = std::collections::BTreeMap::new();
- visit(root, &root.join(".agent"), &mut snapshot);
- snapshot
-}
-
-fn persist_legacy_game_chat_art_retry_run(
+fn claim_game_chat_main_ready_deliveries_with_run_status(
root: &Path,
- original_child: &AgentRuntimeState,
- retry_run_id: &str,
-) -> AgentRuntimeState {
- let parent_agent_id = original_child
- .parent_agent_id
- .as_deref()
- .expect("game-chat art child has parent agent");
- let parent_run_id = original_child
- .parent_run_id
- .as_deref()
- .expect("game-chat art child has parent run");
- let retry_link = AgentRuntimeTaskLink {
- parent_agent_id: Some(parent_agent_id.to_string()),
- parent_run_id: Some(parent_run_id.to_string()),
- delegation_id: Some(format!("legacy-retry-{retry_run_id}")),
- };
- let binding = bind_game_creator_agent_runtime_run_profile_at(
+ main: &AgentRuntimeState,
+) -> (AgentRuntimePendingToolAction, AgentRuntimeToolObservation) {
+ let plan = game_chat_fast_path_plan_at(root, main, &main.current_task, unix_timestamp())
+ .expect("build deterministic game-chat receipt claim plan")
+ .expect("ready game-chat delivery must produce a deterministic claim plan");
+ assert_eq!(plan.actions.len(), 1);
+ assert_eq!(plan.actions[0].tool, "agent.run_status");
+ let action = plan.actions[0].clone();
+ let revision = read_game_creator_agent_runtime_project_revision(root)
+ .expect("read game-chat receipt claim revision");
+ let repository_context_fingerprint = build_repository_startup_context_at(root)
+ .expect("build game-chat receipt claim repository context")
+ .fingerprint;
+ let pending = build_game_creator_agent_runtime_pending_tool_action(
root,
- &original_child.agent_id,
- retry_run_id,
- "agent-delegate-retry",
- Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
- Some(&retry_link),
+ main,
+ &main.current_task,
+ &plan,
+ &[],
+ &revision,
+ &repository_context_fingerprint,
+ &action,
+ 0,
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
+ None,
)
- .expect("bind legacy game-chat art retry run");
- let mut retry = read_latest_game_creator_agent_runtime_task_by_run_id(
+ .expect("build game-chat receipt claim pending action");
+ let observation = observe_agent_runtime_run_status(
root,
- &original_child.agent_id,
- &original_child.run_id,
- )
- .expect("read original game-chat art child")
- .expect("original game-chat art child exists");
- retry.run_id = retry_run_id.to_string();
- retry.source = "agent-delegate-retry".to_string();
- retry.run_profile = binding.profile;
- retry.run_profile_binding_fingerprint = binding.binding_fingerprint;
- retry.delegation_id = retry_link.delegation_id;
- retry.status = "running".to_string();
- retry.phase = "planning".to_string();
- retry.current_action = "遗留 retry 等待执行".to_string();
- retry.terminal_detail = None;
- retry.error = None;
- retry.updated_at = unix_timestamp();
- let state = agent_runtime_state_from_task_record(&retry);
- append_game_creator_agent_runtime_task(root, &state)
- .expect("persist legacy game-chat art retry task");
- write_game_creator_agent_runtime_state(root, &state)
- .expect("persist legacy game-chat art retry state");
- state
-}
-
-#[test]
-fn game_chat_dynamic_art_terminal_children_reject_generic_retry_without_durable_side_effects() {
- for (case_index, (agent_id, missing_slots, terminal_status)) in [
- (
- "art-director",
- vec!["art-spec", "core-spritesheet"],
- "failed",
- ),
- (
- "art-director",
- vec!["art-spec", "core-spritesheet"],
- "cancelled",
- ),
- ("art-asset-plan", vec!["core-spritesheet"], "failed"),
- ("art-asset-plan", vec!["core-spritesheet"], "cancelled"),
- ]
- .into_iter()
- .enumerate()
- {
- let temporary = tempfile::tempdir().expect("create terminal art retry root");
- let root = temporary.path().join("project");
- let (_main, mut child, _) =
- game_chat_main_art_child_fixture(&root, agent_id, &missing_slots);
- child.status = terminal_status.to_string();
- child.phase = terminal_status.to_string();
- child.current_action = "等待用户恢复".to_string();
- child.error = (terminal_status == "failed").then(|| "kind=test-failure".to_string());
- append_game_creator_agent_runtime_task(&root, &child)
- .expect("persist terminal game-chat art child task");
- write_game_creator_agent_runtime_state(&root, &child)
- .expect("persist terminal game-chat art child state");
- fs::remove_file(game_creator_agent_runtime_task_path(
- &root,
- "code-prototype",
- ))
- .expect("remove parent task journal without removing immutable binding lineage");
- fs::remove_file(game_creator_agent_runtime_task_path(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- ))
- .expect("remove root task journal without removing immutable binding lineage");
- let terminal_child = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("read terminal game-chat art child")
- .expect("terminal game-chat art child exists");
- assert!(
- game_chat_dynamic_art_child_structural_identity_at(&root, &terminal_child)
- .expect("immutable binding lineage still identifies terminal art child")
- );
- let durable_before = snapshot_agent_durable_files(&root);
- let retry_run_id = format!("blocked-game-chat-art-retry-{case_index}");
-
- let error = retry_game_creator_agent_runtime_task_at(
- &root,
- &child.agent_id,
- &child.run_id,
- &retry_run_id,
- )
- .expect_err("game-chat dynamic art child retry must be rejected");
-
- assert!(
- error.contains("kind=game-chat-dynamic-art-retry-unsupported"),
- "unexpected typed retry error: {error}"
- );
- assert!(error.contains("继续 game-chat 对话"), "{error}");
- assert!(error.contains("下一轮 main code-prototype"), "{error}");
- assert!(error.contains("asset.list"), "{error}");
- assert_eq!(
- snapshot_agent_durable_files(&root),
- durable_before,
- "blocked retry must not persist any successor state: {agent_id}/{terminal_status}"
- );
- assert!(read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- &child.agent_id,
- &retry_run_id,
- )
- .expect("inspect blocked retry successor")
- .is_none());
- }
-}
-
-#[tokio::test]
-async fn legacy_game_chat_dynamic_art_retry_runs_are_read_only_and_fail_closed() {
- let temporary = tempfile::tempdir().expect("create legacy art retry root");
- let root = temporary.path().join("project");
- let (_main, child, _) =
- game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
- let retry =
- persist_legacy_game_chat_art_retry_run(&root, &child, "legacy-game-chat-art-retry-run");
- let retry_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- &retry.agent_id,
- &retry.run_id,
- )
- .expect("read legacy art retry")
- .expect("legacy art retry exists");
- assert!(
- game_chat_dynamic_art_child_structural_identity_at(&root, &retry_task)
- .expect("classify legacy art retry lineage")
+ &main.agent_id,
+ &main.run_id,
+ Some(&pending.action_id),
+ &action.input,
);
-
- for read_only_tool in ["asset.list", "file.read", "project.diff", "image.inspect"] {
- assert!(
- game_chat_delegated_art_agent_input_mutation_block(
- &root,
- &retry.agent_id,
- &retry.run_id,
- read_only_tool,
- &serde_json::json!({}),
- )
- .is_none(),
- "diagnostic read-only tool must remain available: {read_only_tool}"
- );
- }
-
- let actions = [
- AgentRuntimeToolAction {
- tool: "file.write".to_string(),
- reason: None,
- input: serde_json::json!({"path":"assets/retry-must-not-write.txt","content":"blocked"}),
- },
- AgentRuntimeToolAction {
- tool: "project.patchset".to_string(),
- reason: None,
- input: serde_json::json!({"changes":[{"path":"assets/retry-patchset.txt","operation":"write","content":"blocked"}]}),
- },
- AgentRuntimeToolAction {
- tool: "canvas.asset_generate".to_string(),
- reason: None,
- input: serde_json::json!({"outputPath":"assets/art-spritesheet.png","assetKind":"art-spritesheet"}),
- },
- AgentRuntimeToolAction {
- tool: "memory.write".to_string(),
- reason: None,
- input: serde_json::json!({"scope":"project","title":"blocked","content":"blocked","mode":"append"}),
- },
- AgentRuntimeToolAction {
- tool: "task.create".to_string(),
- reason: None,
- input: serde_json::json!({"taskId":"blocked-retry-task"}),
- },
- AgentRuntimeToolAction {
- tool: "command.run_limited".to_string(),
- reason: None,
- input: serde_json::json!({"command":"npm test"}),
- },
- AgentRuntimeToolAction {
- tool: "preview.validate".to_string(),
- reason: None,
- input: serde_json::json!({}),
- },
- AgentRuntimeToolAction {
- tool: "agent.delegate".to_string(),
- reason: None,
- input: serde_json::json!({"agentId":"art-director","task":"blocked"}),
- },
- AgentRuntimeToolAction {
- tool: "agent.spawn_isolated".to_string(),
- reason: None,
- input: serde_json::json!({"children":[]}),
- },
- ];
- for action in actions {
- let observation = execute_game_creator_agent_runtime_tool_action(
- &root,
- &retry.agent_id,
- &retry.run_id,
- &retry.current_task,
- &action,
- )
- .await;
- assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}");
- assert!(
- observation.summary.contains("不支持通用 retry"),
- "{action:?}: {observation:?}"
- );
- }
- assert!(game_chat_delegated_art_agent_project_path_mutation_block(
- &root,
- &retry.agent_id,
- &retry.run_id,
- "file.write",
- "assets/direct-path-bypass.txt",
- )
- .is_some());
- assert!(!root.join("assets/retry-must-not-write.txt").exists());
- assert!(!root.join("assets/retry-patchset.txt").exists());
-
- fs::remove_file(game_creator_agent_runtime_task_path(
- &root,
- "code-prototype",
- ))
- .expect("remove parent task journal for forged legacy fixture");
- let forged_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- &retry.agent_id,
- &retry.run_id,
- )
- .expect("read forged art retry")
- .expect("forged art retry exists");
- assert!(
- game_chat_dynamic_art_child_structural_identity_at(&root, &forged_task)
- .expect("immutable bindings still prove structural identity")
- );
- assert!(
- !game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at(
- &root,
- &retry.agent_id,
- &retry.run_id,
- AGENT_RUNTIME_ART_SPRITESHEET_PATH,
- )
- .expect("missing parent task cannot prove strict authorization"),
- "retry without a live parent task must not acquire strict authorization"
- );
- let forged_action = AgentRuntimeToolAction {
- tool: "file.write".to_string(),
- reason: None,
- input: serde_json::json!({
- "path":"assets/forged-retry-must-not-write.txt",
- "content":"blocked",
- }),
- };
- let forged_observation = execute_game_creator_agent_runtime_tool_action(
- &root,
- &retry.agent_id,
- &retry.run_id,
- &retry.current_task,
- &forged_action,
- )
- .await;
- assert_eq!(
- forged_observation.status, "blocked",
- "{forged_observation:?}"
- );
- assert!(
- forged_observation.summary.contains("不支持通用 retry"),
- "{forged_observation:?}"
- );
- assert!(
- forged_observation
- .detail
- .as_deref()
- .is_some_and(|detail| detail.contains("下一轮 main code-prototype")),
- "{forged_observation:?}"
- );
- assert!(!root.join("assets/forged-retry-must-not-write.txt").exists());
-}
-
-#[test]
-fn non_delegated_art_sources_stay_out_of_game_chat_art_gate() {
- for source in ["agent-ready-task-scheduler", "agent-background-task"] {
- let temporary = tempfile::tempdir().expect("create non-delegated art root");
- let root = temporary.path().join("project");
- init_local_game_project_at(&root, "non-delegated-art", "非委派美术来源")
- .expect("init non-delegated art project");
- let run_id = format!("non-delegated-art-{source}");
- start_game_creator_agent_runtime_task_at(
- &root,
- "art-director",
- "非委派来源不得进入 game-chat 美术谱系门",
- &run_id,
- source,
- "校验工具边界",
- vec!["非委派来源不受美术门限制".to_string()],
- )
- .expect("start non-delegated art runtime");
- for (tool, input) in [
- (
- "command.output_read",
- serde_json::json!({"actionId":"history-probe","startLine":1,"maxLines":5}),
- ),
- (
- "canvas.asset_generate",
- serde_json::json!({"prompt":"恢复视觉规范图","outputPath":AGENT_RUNTIME_ART_SPEC_PATH}),
- ),
- (
- "file.write",
- serde_json::json!({"path":"game/notes.txt","content":"scoped by other policies"}),
- ),
- ] {
- assert!(
- game_chat_delegated_art_agent_input_mutation_block(
- &root,
- "art-director",
- &run_id,
- tool,
- &input,
- )
- .is_none(),
- "{source}/{tool} 不应被 game-chat 美术门拦截"
- );
- }
- }
-}
-
-#[test]
-fn game_chat_dynamic_art_retry_guard_preserves_dag_art_and_non_art_delegated_retry() {
- let dag_temporary = tempfile::tempdir().expect("create full DAG art retry root");
- let dag_root = dag_temporary.path().join("project");
- init_local_game_project_at(&dag_root, "full-dag-art-retry", "完整 DAG 美术重试")
- .expect("init full DAG art retry project");
- let dag_root_session = resolve_agent_conversation_session_id_at(
- &dag_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- None,
- true,
- )
- .expect("resolve full DAG root session");
- let dag_root_record = append_unique_game_creator_agent_runtime_pending_task(
- &dag_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- &dag_root_session,
- "执行完整任务图",
- "full-dag-art-retry-root",
- AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
- Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
- None,
- )
- .expect("queue full DAG root");
- let mut dag_root_state = agent_runtime_state_from_task_record(&dag_root_record);
- dag_root_state.status = "running".to_string();
- dag_root_state.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&dag_root, &dag_root_state)
- .expect("persist running full DAG root");
- write_game_creator_agent_runtime_state(&dag_root, &dag_root_state)
- .expect("persist full DAG root state");
- let dag_manifest = read_manifest_for_project(&dag_root).expect("read full DAG manifest");
- let dag_art_task = dag_manifest
- .tasks
- .iter()
- .find(|task| task.id == "art-director")
- .expect("full DAG art-director task");
- let dag_art_session =
- resolve_agent_conversation_session_id_at(&dag_root, "art-director", None, true)
- .expect("resolve full DAG art session");
- let dag_art_record = append_unique_game_creator_agent_runtime_pending_task(
- &dag_root,
- "art-director",
- &dag_art_session,
- &render_autonomous_manifest_ready_task_background_prompt(dag_art_task),
- "full-dag-art-failed-run",
- "agent-ready-task-scheduler",
- None,
- Some(&AgentRuntimeTaskLink {
- parent_agent_id: Some(dag_root_state.agent_id.clone()),
- parent_run_id: Some(dag_root_state.run_id.clone()),
- delegation_id: None,
- }),
- )
- .expect("queue full DAG art task");
- let mut dag_art_state = agent_runtime_state_from_task_record(&dag_art_record);
- dag_art_state.status = "failed".to_string();
- dag_art_state.phase = "failed".to_string();
- dag_art_state.error = Some("kind=test-failure".to_string());
- append_game_creator_agent_runtime_task(&dag_root, &dag_art_state)
- .expect("persist failed full DAG art task");
- write_game_creator_agent_runtime_state(&dag_root, &dag_art_state)
- .expect("persist failed full DAG art state");
- let dag_art_lane = try_acquire_game_creator_agent_runtime_task_lock(&dag_root, "art-director")
- .expect("acquire full DAG art lane")
- .expect("full DAG art lane is free");
- let dag_retry = retry_game_creator_agent_runtime_task_at(
- &dag_root,
- "art-director",
- &dag_art_state.run_id,
- "full-dag-art-retry-run",
- )
- .expect("full DAG art retry semantics remain available");
- assert_eq!(
- dag_retry.accepted_run_id.as_deref(),
- Some("full-dag-art-retry-run")
- );
- let dag_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &dag_root,
- "art-director",
- "full-dag-art-retry-run",
- )
- .expect("read full DAG art retry")
- .expect("full DAG art retry exists");
- assert_eq!(dag_retry_task.source, "agent-delegate-retry");
- assert_eq!(
- dag_retry_task.parent_agent_id.as_deref(),
- Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
- );
- assert!(
- !game_chat_dynamic_art_child_structural_identity_at(&dag_root, &dag_retry_task)
- .expect("classify full DAG art retry")
- );
- assert!(game_chat_delegated_art_agent_input_mutation_block(
- &dag_root,
- "art-director",
- "full-dag-art-retry-run",
- "file.write",
- &serde_json::json!({"path":AGENT_RUNTIME_ART_SPEC_PATH,"content":"unchanged semantics"}),
- )
- .is_none());
-
- fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
- &dag_root,
- "art-director",
- "full-dag-art-retry-run",
- ))
- .expect("remove full DAG retry binding for unproven-lineage fixture");
- let unproven_block = game_chat_delegated_art_agent_project_path_mutation_block(
- &dag_root,
- "art-director",
- "full-dag-art-retry-run",
- "file.write",
- "assets/unproven-retry-must-not-write.txt",
- )
- .expect("unproven retry-source art lineage must fail closed");
- assert!(
- unproven_block.summary.contains("不支持通用 retry"),
- "{unproven_block:?}"
- );
- drop(dag_art_lane);
-
- let delegated_temporary = tempfile::tempdir().expect("create non-art delegated retry root");
- let delegated_root = delegated_temporary.path().join("project");
- init_local_game_project_at(&delegated_root, "non-art-delegated-retry", "非美术委派重试")
- .expect("init non-art delegated retry project");
- let parent_session = resolve_agent_conversation_session_id_at(
- &delegated_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- None,
- true,
- )
- .expect("resolve non-art parent session");
- let parent_record = append_unique_game_creator_agent_runtime_pending_task(
- &delegated_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- &parent_session,
- "委派非美术任务",
- "non-art-delegated-parent",
- "agent-background-task",
- Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
- None,
- )
- .expect("queue non-art parent");
- let mut parent_state = agent_runtime_state_from_task_record(&parent_record);
- parent_state.status = "running".to_string();
- parent_state.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&delegated_root, &parent_state)
- .expect("persist running non-art parent");
- write_game_creator_agent_runtime_state(&delegated_root, &parent_state)
- .expect("persist non-art parent state");
- let child_session =
- resolve_agent_conversation_session_id_at(&delegated_root, "design-director", None, true)
- .expect("resolve non-art child session");
- let child_record = append_unique_game_creator_agent_runtime_pending_task(
- &delegated_root,
- "design-director",
- &child_session,
- "完成非美术设计任务",
- "non-art-delegated-failed-run",
- "agent-delegate",
- None,
- Some(&AgentRuntimeTaskLink {
- parent_agent_id: Some(parent_state.agent_id.clone()),
- parent_run_id: Some(parent_state.run_id.clone()),
- delegation_id: Some("non-art-original-delegation".to_string()),
- }),
- )
- .expect("queue non-art delegated child");
- let mut child_state = agent_runtime_state_from_task_record(&child_record);
- child_state.status = "failed".to_string();
- child_state.phase = "failed".to_string();
- child_state.error = Some("kind=test-failure".to_string());
- append_game_creator_agent_runtime_task(&delegated_root, &child_state)
- .expect("persist failed non-art child");
- write_game_creator_agent_runtime_state(&delegated_root, &child_state)
- .expect("persist failed non-art child state");
- let non_art_lane =
- try_acquire_game_creator_agent_runtime_task_lock(&delegated_root, "design-director")
- .expect("acquire non-art child lane")
- .expect("non-art child lane is free");
- let delegated_retry = retry_game_creator_agent_runtime_task_at(
- &delegated_root,
- "design-director",
- &child_state.run_id,
- "non-art-delegated-retry-run",
- )
- .expect("non-art delegated retry semantics remain available");
- assert_eq!(
- delegated_retry.accepted_run_id.as_deref(),
- Some("non-art-delegated-retry-run")
- );
- let delegated_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &delegated_root,
- "design-director",
- "non-art-delegated-retry-run",
- )
- .expect("read non-art delegated retry")
- .expect("non-art delegated retry exists");
- assert_eq!(delegated_retry_task.source, "agent-delegate-retry");
- drop(non_art_lane);
-}
-
-#[test]
-fn game_chat_failed_art_child_can_only_redelegate_in_the_next_main_round() {
- for (case_index, terminal_status) in ["failed", "cancelled"].into_iter().enumerate() {
- let temporary = tempfile::tempdir().expect("create cross-round art recovery root");
- let root = temporary.path().join("project");
- init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派")
- .expect("pre-initialize cross-round art recovery project");
- let _main_runtime_lane =
- try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype")
- .expect("acquire cross-round main runtime lane")
- .expect("cross-round main runtime lane is free");
- let _art_runtime_lane =
- try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan")
- .expect("acquire cross-round art runtime lane")
- .expect("cross-round art runtime lane is free");
- let (mut first_main, mut terminal_child, first_delegation_id) =
- game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
- first_main.status = "running".to_string();
- first_main.phase = "waiting-for-delegate-receipts".to_string();
- first_main.current_action = "等待失败美术 child 回执".to_string();
- append_game_creator_agent_runtime_task(&root, &first_main)
- .expect("persist first waiting main task");
- write_game_creator_agent_runtime_state(&root, &first_main)
- .expect("persist first waiting main state");
-
- terminal_child.status = terminal_status.to_string();
- terminal_child.phase = terminal_status.to_string();
- terminal_child.current_action = "美术 child 未能交付图集".to_string();
- terminal_child.error =
- (terminal_status == "failed").then(|| "kind=cross-round-art-child-failure".to_string());
- append_game_creator_agent_runtime_task(&root, &terminal_child)
- .expect("persist terminal first-round art child");
- write_game_creator_agent_runtime_state(&root, &terminal_child)
- .expect("persist terminal first-round art child state");
- let terminal_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- &terminal_child.agent_id,
- &terminal_child.run_id,
- )
- .expect("read terminal first-round art child")
- .expect("terminal first-round art child exists");
- publish_game_creator_agent_delegate_result(
- &root,
- &terminal_task,
- Some("美术 child 未完成,等待主 Agent 认领失败回执"),
- );
- let ready_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id)
- .expect("read ready failed art delivery")
- .expect("failed art delivery exists");
- assert_eq!(ready_delivery.status, StaticDelegateDeliveryStatus::Ready);
- assert_eq!(
- ready_delivery.terminal_status.as_deref(),
- Some(terminal_status)
- );
- let claimed = claim_ready_static_delegate_receipts_at(
- &root,
- "code-prototype",
- &first_main.run_id,
- &format!("cross-round-art-claim-{case_index}"),
- )
- .expect("claim failed art delivery");
- assert_eq!(claimed.len(), 1);
- let claimed_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id)
- .expect("read claimed failed art delivery")
- .expect("claimed failed art delivery exists");
- assert_eq!(
- claimed_delivery.status,
- StaticDelegateDeliveryStatus::ClaimedByParent
- );
-
- let same_run_action_id = format!("same-run-art-redelegate-{case_index}");
- let same_run = observe_agent_runtime_agent_delegate(
- &root,
- "code-prototype",
- &first_main.run_id,
- Some(&same_run_action_id),
- &serde_json::json!({
- "agentId": "art-asset-plan",
- "task": "同一 main run 不得再次尝试同一缺口",
- "acceptanceCriteria": ["不应创建第二个 child"],
- "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH],
- "runId": null
- }),
- );
- assert_eq!(same_run.status, "failed", "{same_run:?}");
- assert!(same_run.summary.contains("最多委派一次"), "{same_run:?}");
- let rejected_delegation_id = agent_runtime_delegation_id(
- "code-prototype",
- &first_main.run_id,
- "art-asset-plan",
- &same_run_action_id,
- );
- assert!(
- read_static_delegate_delivery_at(&root, &rejected_delegation_id)
- .expect("inspect rejected same-run delivery")
- .is_none(),
- "same-run rejection must not create a second delivery"
- );
-
- first_main.status = "failed".to_string();
- first_main.phase = "failed".to_string();
- first_main.current_action = "本轮在失败美术回执后结束".to_string();
- append_game_creator_agent_runtime_task(&root, &first_main)
- .expect("persist terminal first main task");
- write_game_creator_agent_runtime_state(&root, &first_main)
- .expect("persist terminal first main state");
- let first_root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- "game-chat-main-art-root",
- )
- .expect("read first game-chat root")
- .expect("first game-chat root exists");
- let mut first_root_state = agent_runtime_state_from_task_record(&first_root_task);
- first_root_state.status = "failed".to_string();
- first_root_state.phase = "failed".to_string();
- first_root_state.current_action = "等待用户继续 game-chat 对话".to_string();
- append_game_creator_agent_runtime_task(&root, &first_root_state)
- .expect("persist terminal first root task");
- write_game_creator_agent_runtime_state(&root, &first_root_state)
- .expect("persist terminal first root state");
-
- let next_root_run_id = format!("game-chat-cross-round-recovery-root-{case_index}");
- let (next_root, mut next_main) = queue_game_chat_fast_path_child(
- &root,
- &next_root_run_id,
- "继续 game-chat 对话并重新审计仍存在的美术缺口",
- "code-prototype",
- );
- next_main.status = "running".to_string();
- next_main.phase = "planning".to_string();
- next_main.current_task = "下一轮重新审计并补齐仍存在的美术缺口".to_string();
- next_main.current_action = "重新执行 asset.list 审计".to_string();
- next_main
- .recent_tool_calls
- .push(AgentRuntimeToolCallRecord {
- action_id: Some(format!("next-round-asset-list-{case_index}")),
- tool: "asset.list".to_string(),
- status: "ok".to_string(),
- action_fingerprint: None,
- input_summary: None,
- reason: Some("下一轮重新审计当前项目资产".to_string()),
- summary: "已确认核心图集仍缺失".to_string(),
- detail: None,
- updated_at: unix_timestamp(),
- });
- append_game_creator_agent_runtime_task(&root, &next_main)
- .expect("persist next-round main asset audit task");
- write_game_creator_agent_runtime_state(&root, &next_main)
- .expect("persist next-round main asset audit state");
- let audited_runtime = read_game_creator_agent_runtime_for_session_at(
- &root,
- "code-prototype",
- Some(&next_main.session_id),
- )
- .expect("read next-round audited main runtime")
- .state;
- assert_eq!(audited_runtime.run_id, next_main.run_id);
- persist_game_chat_supervisor_workflow_decision_at(
- &root,
- &next_root.agent_id,
- &next_root.run_id,
- GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST,
- "下一轮继续补齐仍存在的核心图集缺口",
- )
- .expect("persist next-round Supervisor decision");
- let next_coverage =
- game_chat_current_asset_coverage_at(&root, &next_root.run_id, &next_main.run_id)
- .expect("calculate next-round asset coverage");
- assert_eq!(next_coverage.missing_slots, vec!["core-spritesheet"]);
- persist_game_chat_code_asset_route_at(
- &root,
- &next_main.agent_id,
- &next_main.run_id,
- GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING,
- &next_coverage.missing_slots,
- )
- .expect("persist next-round asset route");
-
- let next_action_id = format!("next-round-art-delegate-{case_index}");
- let next_round = observe_agent_runtime_agent_delegate(
- &root,
- "code-prototype",
- &next_main.run_id,
- Some(&next_action_id),
- &serde_json::json!({
- "agentId": "art-asset-plan",
- "task": "补齐下一轮审计确认仍缺失的核心图集",
- "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"],
- "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH],
- "runId": null
- }),
- );
- assert_eq!(next_round.status, "ok", "{next_round:?}");
- let next_delegation_id = agent_runtime_delegation_id(
- "code-prototype",
- &next_main.run_id,
- "art-asset-plan",
- &next_action_id,
- );
- assert_ne!(next_delegation_id, first_delegation_id);
- let next_delivery = read_static_delegate_delivery_at(&root, &next_delegation_id)
- .expect("read next-round art delivery")
- .expect("next-round art delivery exists");
- let next_child = read_latest_game_creator_agent_runtime_task_by_delegation_id(
- &root,
- "art-asset-plan",
- &next_delegation_id,
- )
- .expect("read next-round art child")
- .expect("next-round art child exists");
- assert_eq!(
- next_delivery.status,
- StaticDelegateDeliveryStatus::Dispatched
- );
- assert_eq!(next_delivery.parent_run_id, next_main.run_id);
- assert_eq!(next_delivery.target_run_id, next_child.run_id);
- assert_eq!(next_delivery.target_agent_id, "art-asset-plan");
- assert_eq!(
- next_delivery.expected_artifacts,
- [AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()]
- );
- assert!(next_delivery.terminal_status.is_none());
-
- let mut next_child_state = agent_runtime_state_from_task_record(&next_child);
- next_child_state.status = "running".to_string();
- next_child_state.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&root, &next_child_state)
- .expect("persist running next-round art child");
- write_game_creator_agent_runtime_state(&root, &next_child_state)
- .expect("persist running next-round art child state");
- assert!(
- game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at(
- &root,
- "art-asset-plan",
- &next_child_state.run_id,
- AGENT_RUNTIME_ART_SPRITESHEET_PATH,
- )
- .expect("verify next-round art lineage")
- );
- assert!(
- game_chat_delegated_art_agent_project_path_mutation_block(
- &root,
- "art-asset-plan",
- &next_child_state.run_id,
- "file.write",
- AGENT_RUNTIME_ART_SPRITESHEET_PATH,
- )
- .is_none(),
- "new parent run and new delivery must restore assets-only authorization"
- );
- }
+ assert_eq!(observation.status, "ok", "{observation:?}");
+ mark_supervisor_delivery_claims_observed_for_pending_action_at(root, &pending, &observation)
+ .expect("mark game-chat parent receipt claim observed");
+ (pending, observation)
}
fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
@@ -1299,10 +499,7 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre
_ => unreachable!(),
};
assert_eq!(observation.status, "blocked", "{tool}: {observation:?}");
- assert!(
- observation.summary.contains("只能修改 assets/**"),
- "{action:?}: {observation:?}"
- );
+ assert!(observation.summary.contains("只能修改 assets/**"));
}
let patchset = observe_agent_runtime_project_patchset(
@@ -1360,18 +557,6 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre
}),
},
] {
- let preflight = game_chat_delegated_art_agent_input_mutation_block(
- &root,
- &child.agent_id,
- &child.run_id,
- &action.tool,
- &action.input,
- )
- .unwrap_or_else(|| panic!("delegated art preflight must block {action:?}"));
- assert!(
- preflight.summary.contains("只能修改 assets/**"),
- "{action:?}: {preflight:?}"
- );
let observation = execute_game_creator_agent_runtime_tool_action(
&root,
&child.agent_id,
@@ -1381,10 +566,7 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre
)
.await;
assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}");
- assert!(
- observation.summary.contains("只能修改 assets/**"),
- "{action:?}: {observation:?}"
- );
+ assert!(observation.summary.contains("只能修改 assets/**"));
}
assert_eq!(fs::read(root.join("memory/project.md")).ok(), memory_before);
assert_eq!(
@@ -1415,324 +597,6 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre
);
}
-#[test]
-fn game_chat_delegated_art_asset_plan_keeps_canvas_verification_and_rejects_borrowed_lineage() {
- let temporary = tempfile::tempdir().expect("create game-chat Canvas delivery root");
- let root = temporary.path().join("project");
- init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派")
- .expect("pre-initialize game-chat Canvas delivery project");
- let _child_runtime_lane =
- try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan")
- .expect("acquire game-chat Canvas art child runtime lane")
- .expect("game-chat Canvas art child runtime lane is free");
- let (_main, mut child, delegation_id) =
- game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
- child.status = "running".to_string();
- child.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&root, &child)
- .expect("persist running game-chat art child");
- write_game_creator_agent_runtime_state(&root, &child)
- .expect("persist running game-chat art runtime");
-
- assert!(!autonomous_owner_artifact_validation_available_for_run_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("evaluate fixed owner validation identity"));
- assert!(
- game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("validate game-chat delegated art lineage")
- );
-
- {
- let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
- &root,
- "test.game-chat-canvas-delivery",
- )
- .expect("lock game-chat Canvas delivery root");
- prepare_agent_runtime_project_mutation_locked(
- &root,
- &child.agent_id,
- &child.run_id,
- "canvas.asset_generate",
- )
- .expect("record Canvas mutation");
- let (expected_revision, gate) = begin_agent_runtime_project_verification_locked(
- &root,
- &child.agent_id,
- &child.run_id,
- "canvas.asset_generate",
- )
- .expect("begin Canvas verification");
- finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true)
- .expect("finish Canvas verification");
- }
- let plan = AgentRuntimeToolPlan {
- response: "已按委派生成并登记图集素材。".to_string(),
- ..AgentRuntimeToolPlan::default()
- };
- let gate =
- read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id)
- .expect("read Canvas verification gate");
- assert_eq!(
- gate.last_verification_tool.as_deref(),
- Some("canvas.asset_generate")
- );
- validate_agent_runtime_autonomous_specialist_response_delivery(
- &root,
- &child.agent_id,
- &child.run_id,
- false,
- false,
- &gate,
- &plan,
- )
- .expect("trusted game-chat art child may finalize with Canvas verification");
-
- let mut borrowed_project_verify_gate = gate.clone();
- borrowed_project_verify_gate.last_verification_tool = Some("project.verify".to_string());
- write_game_creator_agent_runtime_verification_gate(&root, &borrowed_project_verify_gate)
- .expect("persist deliberately borrowed project.verify gate");
- assert!(
- validate_agent_runtime_autonomous_specialist_response_delivery(
- &root,
- &child.agent_id,
- &child.run_id,
- false,
- false,
- &borrowed_project_verify_gate,
- &plan,
- )
- .expect_err("trusted lineage must still require its own Canvas credential")
- .contains("只接受本人 canvas.asset_generate")
- );
- let borrowed_completion_blocker =
- project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[])
- .expect("borrowed project.verify must fail the completion gate");
- assert!(borrowed_completion_blocker
- .summary
- .contains("Canvas 交付凭证"));
- write_game_creator_agent_runtime_verification_gate(&root, &gate)
- .expect("restore Canvas verification gate");
- let restored_blocker =
- project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]);
- assert!(
- restored_blocker.is_none(),
- "restored Canvas credential must satisfy the dynamic art completion gate: {restored_blocker:?}"
- );
- assert_eq!(
- read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id,)
- .expect("reread Canvas verification gate")
- .last_verification_tool
- .as_deref(),
- Some("canvas.asset_generate")
- );
-
- let delivery = read_static_delegate_delivery_at(&root, &delegation_id)
- .expect("read game-chat Canvas delivery")
- .expect("game-chat Canvas delivery exists");
- let mut wrong_artifact_delivery = delivery.clone();
- wrong_artifact_delivery.expected_artifacts = vec!["assets/other.png".to_string()];
- write_agent_runtime_json_sidecar(
- &root,
- &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"),
- "forged game-chat Canvas delivery",
- &wrong_artifact_delivery,
- )
- .expect("persist forged game-chat Canvas artifact contract");
- assert!(
- !game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("reject game-chat Canvas delivery with wrong expected artifact")
- );
- write_agent_runtime_json_sidecar(
- &root,
- &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"),
- "restored game-chat Canvas delivery",
- &delivery,
- )
- .expect("restore game-chat Canvas artifact contract");
-
- // A task journal and binding that say `agent-delegate` are not sufficient:
- // removing the Runtime-issued durable delivery makes the otherwise valid
- // game-chat lineage ineligible for the narrow Canvas exception.
- fs::remove_file(root.join(format!(
- ".agent/runtime/delegation-deliveries/{delegation_id}.json"
- )))
- .expect("remove durable delegation proof");
- let forged_asset_path = "assets/forged-without-delivery.txt";
- let forged_asset_write = observe_agent_runtime_file_write(
- &root,
- &child.agent_id,
- &child.run_id,
- &AgentRuntimeToolAction {
- tool: "file.write".to_string(),
- reason: Some("验证伪造 game-chat 美术 lineage 不能取得 assets 写权限".to_string()),
- input: serde_json::json!({
- "path": forged_asset_path,
- "content": "forged delegated art mutation",
- }),
- },
- &"0".repeat(64),
- None,
- );
- assert_eq!(
- forged_asset_write.status, "blocked",
- "{forged_asset_write:?}"
- );
- assert!(
- !root.join(forged_asset_path).exists(),
- "missing durable delivery must be rejected before assets/** mutation"
- );
- assert!(
- !game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("reject forged delegated source without durable delivery")
- );
- let forged_completion_blocker =
- project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[])
- .expect("forged delegated source must also fail the durable completion gate");
- assert!(forged_completion_blocker
- .summary
- .contains("不具备可用的验证身份"));
- assert!(
- validate_agent_runtime_autonomous_specialist_response_delivery(
- &root,
- &child.agent_id,
- &child.run_id,
- false,
- false,
- &gate,
- &plan,
- )
- .expect_err("forged delegated source must not borrow Canvas finalization")
- .contains("固定 owner 的 Runtime 内部产物验证身份不可用")
- );
-
- write_agent_runtime_json_sidecar(
- &root,
- &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"),
- "restored game-chat Canvas delivery before root source forgery",
- &delivery,
- )
- .expect("restore durable delivery before root source forgery");
- assert!(
- game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("restored game-chat Canvas lineage remains valid")
- );
- let root_run_id = "game-chat-main-art-root";
- let root_binding = read_game_creator_agent_runtime_run_profile_binding(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- root_run_id,
- )
- .expect("read game-chat Canvas root binding")
- .expect("game-chat Canvas root binding exists");
- assert_eq!(
- root_binding.source,
- AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
- );
- let root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
- &root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- root_run_id,
- )
- .expect("read game-chat Canvas root task")
- .expect("game-chat Canvas root task exists");
- let mut wrong_source_root = agent_runtime_state_from_task_record(&root_task);
- // Keep the signed binding and route coherent so this reaches the
- // task-versus-binding cross-check instead of failing on a broken parent chain.
- wrong_source_root.source = AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string();
- append_game_creator_agent_runtime_task(&root, &wrong_source_root)
- .expect("persist forged non-game-chat root task");
- assert!(
- !game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &root,
- &child.agent_id,
- &child.run_id,
- )
- .expect("reject Canvas exception outside a game-chat root")
- );
-
- let ordinary_temporary = tempfile::tempdir().expect("create ordinary delegated owner root");
- let ordinary_root = ordinary_temporary.path().join("project");
- init_local_game_project_at(
- &ordinary_root,
- "ordinary-delegated-owner",
- "普通委派不能借 game-chat 例外",
- )
- .expect("init ordinary delegated owner project");
- let supervisor_session = resolve_agent_conversation_session_id_at(
- &ordinary_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- None,
- true,
- )
- .expect("resolve ordinary Supervisor session");
- let supervisor_record = append_unique_game_creator_agent_runtime_pending_task(
- &ordinary_root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- &supervisor_session,
- "普通 GUI 自主任务",
- "ordinary-delegated-root-run",
- AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
- Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
- None,
- )
- .expect("queue ordinary Supervisor");
- let mut supervisor_state = agent_runtime_state_from_task_record(&supervisor_record);
- supervisor_state.status = "running".to_string();
- supervisor_state.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&ordinary_root, &supervisor_state)
- .expect("persist running ordinary Supervisor");
- let ordinary_session =
- resolve_agent_conversation_session_id_at(&ordinary_root, "art-asset-plan", None, true)
- .expect("resolve ordinary art session");
- let ordinary_record = append_unique_game_creator_agent_runtime_pending_task(
- &ordinary_root,
- "art-asset-plan",
- &ordinary_session,
- "普通 delegated 美术任务",
- "ordinary-delegated-art-run",
- "agent-delegate",
- None,
- Some(&AgentRuntimeTaskLink {
- parent_agent_id: Some(supervisor_state.agent_id.clone()),
- parent_run_id: Some(supervisor_state.run_id.clone()),
- delegation_id: Some("ordinary-delegation".to_string()),
- }),
- )
- .expect("queue ordinary delegated art task");
- let mut ordinary_child = agent_runtime_state_from_task_record(&ordinary_record);
- ordinary_child.status = "running".to_string();
- ordinary_child.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(&ordinary_root, &ordinary_child)
- .expect("persist running ordinary art child");
- assert!(
- !game_chat_delegated_art_asset_plan_uses_canvas_verification_at(
- &ordinary_root,
- &ordinary_child.agent_id,
- &ordinary_child.run_id,
- )
- .expect("ordinary delegated source is not game-chat lineage")
- );
-}
-
#[test]
fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() {
let temporary = tempfile::tempdir().expect("create wrong art delegation root");
@@ -2026,6 +890,712 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
);
}
+#[test]
+fn game_chat_code_prototype_ignores_suppressed_delivery_without_child_task() {
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create suppressed delivery recovery root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(
+ &root,
+ "game-chat-suppressed-delivery",
+ "忽略未启动 child 的 suppressed delivery 后重试",
+ )
+ .expect("init suppressed delivery recovery project");
+ let (root_state, mut main) = queue_game_chat_fast_path_child(
+ &root,
+ "game-chat-suppressed-delivery-root",
+ "审计已有美术并补齐真实缺口",
+ "code-prototype",
+ );
+ main.status = "running".to_string();
+ main.phase = "planning".to_string();
+ main.current_task = "审计现有素材并按真实缺口委派美术".to_string();
+ main.current_action = "恢复失败的美术委派".to_string();
+ persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main);
+
+ let suppressed = new_static_delegate_delivery_with_contract(
+ &main.agent_id,
+ &main.session_id,
+ &main.run_id,
+ "game-chat-suppressed-before-child-action",
+ "delegation-game-chat-suppressed-before-child",
+ "art-director",
+ "game-chat-suppressed-missing-child-session",
+ "game-chat-suppressed-missing-child-run",
+ &["只写入受委派的 assets 产物并回执".to_string()],
+ &[AGENT_RUNTIME_ART_SPEC_PATH.to_string()],
+ None,
+ );
+ let suppressed = create_or_read_static_delegate_delivery_at(&root, &suppressed)
+ .expect("create delivery before child startup failure");
+ let suppressed = suppress_static_delegate_delivery_at(&root, &suppressed)
+ .expect("suppress delivery whose child never started");
+ assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed);
+ assert!(
+ read_latest_game_creator_agent_runtime_task_by_run_id(
+ &root,
+ "art-director",
+ "game-chat-suppressed-missing-child-run",
+ )
+ .expect("check missing suppressed child")
+ .is_none(),
+ "fixture must preserve the dispatch-before-child-start failure window"
+ );
+
+ assert!(
+ static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id)
+ .expect("suppressed delivery must not invalidate the trusted parent"),
+ "trusted game-chat code parent must retain receipt capability"
+ );
+ assert!(
+ static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id)
+ .expect("read barrier with suppressed delivery")
+ .is_clear(),
+ "suppressed delivery must not block retry or completion"
+ );
+ game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp())
+ .expect("suppressed delivery without child must not fail the fast path");
+
+ let child_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "art-director")
+ .expect("acquire retried art child lane")
+ .expect("retried art child lane available");
+ let retry_action_id = "game-chat-retry-after-suppressed-action";
+ let retry = observe_agent_runtime_agent_delegate(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ Some(retry_action_id),
+ &serde_json::json!({
+ "agentId": "art-director",
+ "task": "只补齐审计确认缺失的美术资产",
+ "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"],
+ "expectedArtifacts": [AGENT_RUNTIME_ART_SPEC_PATH],
+ "repairOfDelegationId": null,
+ "runId": null
+ }),
+ );
+ assert_eq!(retry.status, "ok", "{retry:?}");
+ let retry_delegation_id = agent_runtime_delegation_id(
+ &main.agent_id,
+ &main.run_id,
+ "art-director",
+ retry_action_id,
+ );
+ let retried_child = read_latest_game_creator_agent_runtime_task_by_delegation_id(
+ &root,
+ "art-director",
+ &retry_delegation_id,
+ )
+ .expect("read retried art child")
+ .expect("retried art child exists");
+ cancel_game_creator_agent_runtime_task_at(
+ &root,
+ &retried_child.agent_id,
+ &retried_child.run_id,
+ )
+ .expect("cancel retried art child fixture");
+ drop(child_lane);
+}
+
+#[test]
+fn game_chat_code_prototype_missing_parent_task_with_active_delivery_blocks_completion() {
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create missing parent task root");
+ let root = temporary.path().join("project");
+ let (main, child, _delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-director",
+ &["art-spec", "core-spritesheet"],
+ true,
+ );
+ let child_lane = child_lane.expect("delegated child lane is held");
+ fs::remove_file(game_creator_agent_runtime_task_path(&root, &main.agent_id))
+ .expect("remove code-prototype task journal fixture");
+
+ let capability =
+ static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id);
+ assert!(
+ capability
+ .as_ref()
+ .is_err_and(|error| error.contains("存在未收束静态委派")),
+ "missing parent task with an active delivery must fail closed: {capability:?}"
+ );
+ let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id)
+ .expect("missing parent task must block completion");
+ assert_eq!(blocker.status, "blocked");
+ assert!(blocker.summary.contains("无法确认当前父 Run"));
+
+ cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id)
+ .expect("cancel missing-parent child fixture");
+ drop(child_lane);
+}
+
+#[test]
+fn game_chat_code_prototype_claims_own_failed_art_delivery_and_fails_closed() {
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create game-chat failed art receipt root");
+ let root = temporary.path().join("project");
+ let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-director",
+ &["art-spec", "core-spritesheet"],
+ true,
+ );
+ let child_lane = child_lane.expect("delegated art child lane is held");
+ let dispatched = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read dispatched failed-art delivery")
+ .expect("failed-art delivery exists");
+ let failed_result = build_static_delegate_structured_result_at(
+ &root,
+ "failed",
+ &dispatched.expected_artifacts,
+ false,
+ None,
+ None,
+ None,
+ Some("External Editor 配置缺失,无法生成平台美术资源"),
+ )
+ .expect("build failed art structured result");
+ let ready = mark_static_delegate_delivery_ready_with_result_at(
+ &root,
+ &child.agent_id,
+ &child.session_id,
+ &child.run_id,
+ &delegation_id,
+ "failed",
+ "External Editor 配置缺失,无法生成平台美术资源",
+ failed_result,
+ )
+ .expect("mark failed art delivery ready");
+ assert_eq!(ready.status, StaticDelegateDeliveryStatus::Ready);
+
+ let deterministic_claim =
+ game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp())
+ .expect("build deterministic failed-art claim plan")
+ .expect("ready art delivery must be claimed before another Provider request");
+ assert_eq!(deterministic_claim.actions.len(), 1);
+ assert_eq!(deterministic_claim.actions[0].tool, "agent.run_status");
+ assert_eq!(deterministic_claim.actions[0].input["scope"], "self");
+
+ let claim_action = deterministic_claim.actions[0].clone();
+ let project_revision_before = read_game_creator_agent_runtime_project_revision(&root)
+ .expect("read failed-art claim revision");
+ let repository_context_fingerprint = build_repository_startup_context_at(&root)
+ .expect("build failed-art claim repository context")
+ .fingerprint;
+ let pending = build_game_creator_agent_runtime_pending_tool_action(
+ &root,
+ &main,
+ &main.current_task,
+ &deterministic_claim,
+ &[],
+ &project_revision_before,
+ &repository_context_fingerprint,
+ &claim_action,
+ 0,
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
+ None,
+ )
+ .expect("build failed-art run_status pending action");
+ let observation = observe_agent_runtime_run_status(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ Some(&pending.action_id),
+ &claim_action.input,
+ );
+ assert_eq!(observation.status, "ok", "{observation:?}");
+ let receipt_payload =
+ run_status_prefixed_payload(observation.detail.as_deref(), "readyDelegateReceipts: ")
+ .expect("parse failed art receipt prefix")
+ .expect("failed art receipt payload exists");
+ let receipt_payload: serde_json::Value =
+ serde_json::from_str(receipt_payload).expect("parse failed art receipt JSON");
+ let receipts = receipt_payload["receipts"]
+ .as_array()
+ .expect("failed art receipt array");
+ assert_eq!(receipts.len(), 1);
+ assert_eq!(receipts[0]["delegationId"], delegation_id);
+ assert_eq!(receipts[0]["status"], "failed");
+ mark_supervisor_delivery_claims_observed_for_pending_action_at(&root, &pending, &observation)
+ .expect("mark trusted game-chat parent claim observed");
+ let barrier = static_delegate_completion_barrier_at(&root, &main.agent_id, &main.run_id)
+ .expect("read failed art delivery barrier");
+ assert_eq!(barrier.ready_unclaimed_count, 0);
+ assert_eq!(barrier.unobserved_claim_count, 0);
+ assert_eq!(barrier.repair_required_count, 1);
+
+ let claimed_contract = observe_agent_runtime_run_status(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ Some("game-chat-code-failed-art-contract"),
+ &serde_json::json!({"delegationId": delegation_id}),
+ );
+ assert_eq!(claimed_contract.status, "ok", "{claimed_contract:?}");
+ let contract = claimed_contract
+ .detail
+ .as_deref()
+ .expect("claimed failed art contract detail");
+ let contract: serde_json::Value =
+ serde_json::from_str(contract).expect("parse claimed failed art contract");
+ assert_eq!(
+ contract["claimedDelegateContract"]["terminalStatus"],
+ "failed"
+ );
+ assert_eq!(
+ contract["claimedDelegateContract"]["contractStatus"],
+ "needs-repair"
+ );
+
+ for (agent_id, run_id, rejected_delegation_id) in [
+ (
+ "quality-review",
+ main.run_id.as_str(),
+ delegation_id.as_str(),
+ ),
+ (
+ main.agent_id.as_str(),
+ "game-chat-code-other-run",
+ delegation_id.as_str(),
+ ),
+ (
+ main.agent_id.as_str(),
+ main.run_id.as_str(),
+ "delegation-000000000000000000000000",
+ ),
+ ] {
+ let rejected = observe_agent_runtime_run_status(
+ &root,
+ agent_id,
+ run_id,
+ Some("game-chat-code-cross-scope-contract"),
+ &serde_json::json!({"delegationId": rejected_delegation_id}),
+ );
+ assert_eq!(rejected.status, "failed", "{rejected:?}");
+ assert!(rejected.detail.is_none());
+ assert!(rejected.summary.contains("当前可信父 run 或尚未认领"));
+ }
+ let unchanged = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read unchanged failed-art delivery")
+ .expect("failed-art delivery still exists");
+ assert_eq!(
+ unchanged.status,
+ StaticDelegateDeliveryStatus::ClaimedByParent
+ );
+ assert_eq!(
+ unchanged.claimed_by_action_id.as_deref(),
+ Some(pending.action_id.as_str())
+ );
+
+ let terminal_error =
+ game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp())
+ .expect_err("non-safe-default failed art delivery must stop before Provider planning");
+ assert!(terminal_error.contains("专业美术子任务失败且不能自动返工"));
+ assert!(terminal_error.contains("art-director"));
+
+ cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id)
+ .expect("cancel blocked art child fixture");
+ drop(child_lane);
+}
+
+#[tokio::test]
+async fn game_chat_failed_art_delivery_stops_main_loop_without_provider_request() {
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create failed art main-loop root");
+ let root = temporary.path().join("project");
+ let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-director",
+ &["art-spec", "core-spritesheet"],
+ true,
+ );
+ let child_lane = child_lane.expect("failed art child lane is held");
+ child.status = "failed".to_string();
+ child.phase = "failed".to_string();
+ child.current_action = "External Editor 配置缺失,无法生成平台美术资源".to_string();
+ child.error = Some(child.current_action.clone());
+ append_game_creator_agent_runtime_task(&root, &child)
+ .expect("persist failed art child terminal task");
+ write_game_creator_agent_runtime_state(&root, &child)
+ .expect("persist failed art child terminal runtime");
+ let dispatched = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read main-loop failed art delivery")
+ .expect("main-loop failed art delivery exists");
+ let failed_result = build_static_delegate_structured_result_at(
+ &root,
+ "failed",
+ &dispatched.expected_artifacts,
+ false,
+ None,
+ None,
+ None,
+ Some("External Editor 配置缺失,无法生成平台美术资源"),
+ )
+ .expect("build main-loop failed art result");
+ mark_static_delegate_delivery_ready_with_result_at(
+ &root,
+ &child.agent_id,
+ &child.session_id,
+ &child.run_id,
+ &delegation_id,
+ "failed",
+ "External Editor 配置缺失,无法生成平台美术资源",
+ failed_result,
+ )
+ .expect("mark main-loop failed art delivery ready");
+
+ let outcome = run_game_creator_agent_background_task_pass_with_context(
+ root.clone(),
+ main.agent_id.clone(),
+ main.current_task.clone(),
+ main.clone(),
+ AgentRuntimeContinuationContext::default(),
+ )
+ .await;
+ assert!(
+ matches!(outcome, AgentBackgroundTaskOutcome::Finished),
+ "failed art delivery must terminate the main Run without Provider planning: {outcome:?}"
+ );
+ let terminal = read_game_creator_agent_runtime_for_session_at(
+ &root,
+ &main.agent_id,
+ Some(&main.session_id),
+ )
+ .expect("read terminal failed game-chat main Run")
+ .state;
+ assert_eq!(terminal.run_id, main.run_id);
+ assert_eq!(terminal.status, "failed");
+ assert_eq!(terminal.phase, "failed");
+ assert!(terminal.error.as_deref().is_some_and(|error| {
+ error.contains("专业美术子任务失败且不能自动返工") && error.contains("art-director")
+ }));
+ let records =
+ fs::read_to_string(root.join(".agent/agent.db")).expect("read failed art main-loop audit");
+ assert!(!records.lines().any(|line| {
+ serde_json::from_str::(line)
+ .ok()
+ .is_some_and(|record| {
+ record["recordType"] == "agent.runtime.provider_request.lifecycle"
+ && record["runId"] == main.run_id
+ })
+ }));
+
+ drop(child_lane);
+ crate::tests::wait_for_agent_runtime_terminal_and_lane_release_async(
+ &root,
+ &child.agent_id,
+ &child.run_id,
+ "failed",
+ "failed",
+ )
+ .await;
+}
+
+#[test]
+fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim() {
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create tampered art binding root");
+ let root = temporary.path().join("project");
+ let (main, child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-director",
+ &["art-spec", "core-spritesheet"],
+ true,
+ );
+ let child_lane = child_lane.expect("delegated tampered child lane is held");
+ let dispatched = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read tampered child delivery")
+ .expect("tampered child delivery exists");
+ let failed_result = build_static_delegate_structured_result_at(
+ &root,
+ "failed",
+ &dispatched.expected_artifacts,
+ false,
+ None,
+ None,
+ None,
+ Some("专业美术子任务失败"),
+ )
+ .expect("build tampered child failed result");
+ mark_static_delegate_delivery_ready_with_result_at(
+ &root,
+ &child.agent_id,
+ &child.session_id,
+ &child.run_id,
+ &delegation_id,
+ "failed",
+ "专业美术子任务失败",
+ failed_result,
+ )
+ .expect("mark tampered child delivery ready");
+ let mut tampered = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read ready delivery before identity tampering")
+ .expect("ready delivery exists before identity tampering");
+ tampered.parent_session_id = "tampered-parent-session".to_string();
+ fs::write(
+ root.join(format!(
+ ".agent/runtime/delegation-deliveries/{delegation_id}.json"
+ )),
+ serde_json::to_vec_pretty(&tampered).expect("serialize tampered delivery fixture"),
+ )
+ .expect("persist tampered delivery fixture");
+
+ let error = game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp())
+ .expect_err("tampered delivery identity must fail before receipt claim");
+ assert!(error.contains("delivery 与可信父责任链不一致"), "{error}");
+ assert!(
+ static_delegate_parent_can_manage_receipts_at(&root, &main.agent_id, &main.run_id).is_err(),
+ "tampered delivery must not degrade to an ordinary unauthorized parent"
+ );
+ let blocker = static_delegate_completion_blocker_at_locked(&root, &main.agent_id, &main.run_id)
+ .expect("tampered delivery must block task completion");
+ assert_eq!(blocker.status, "blocked");
+ assert!(blocker.summary.contains("无法确认当前父 Run"));
+ let claim = observe_agent_runtime_run_status(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ Some("game-chat-tampered-delivery-claim"),
+ &serde_json::json!({
+ "agentId": null,
+ "scope": "self",
+ "delegationId": null,
+ }),
+ );
+ assert_eq!(claim.status, "failed", "{claim:?}");
+ let delivery = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read unclaimed tampered delivery")
+ .expect("unclaimed tampered delivery exists");
+ assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Ready);
+ assert!(delivery.claimed_by_action_id.is_none());
+
+ cancel_game_creator_agent_runtime_task_at(&root, &child.agent_id, &child.run_id)
+ .expect("cancel tampered child fixture");
+ drop(child_lane);
+}
+
+#[test]
+fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() {
+ let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root");
+ let root = temporary.path().join("project");
+ let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane(
+ &root,
+ "art-director",
+ &["art-spec", "core-spritesheet"],
+ true,
+ );
+ let child_lane = child_lane.expect("delegated art child lane is held");
+ let needs_input = format!(
+ "AGC_NEEDS_USER_INPUT_V1\n{}",
+ serde_json::json!({
+ "questions": [{
+ "id": "visual_preference",
+ "header": "视觉偏好",
+ "question": "请选择首版视觉偏好",
+ "options": [
+ {"label": "明亮", "description": "使用明亮、安全的默认配色。"},
+ {"label": "柔和", "description": "使用柔和配色。"}
+ ]
+ }]
+ })
+ );
+ child.status = "completed".to_string();
+ child.phase = "completed".to_string();
+ child.current_action = "请求非必要视觉偏好".to_string();
+ append_game_creator_agent_runtime_task(&root, &child)
+ .expect("persist completed art child with clarification");
+ let child_task = read_latest_game_creator_agent_runtime_task_by_run_id(
+ &root,
+ &child.agent_id,
+ &child.run_id,
+ )
+ .expect("read completed clarification child")
+ .expect("completed clarification child exists");
+ publish_game_creator_agent_delegate_result(&root, &child_task, Some(&needs_input));
+ let (_claim_pending, claim_observation) =
+ claim_game_chat_main_ready_deliveries_with_run_status(&root, &main);
+ assert!(
+ claim_observation
+ .detail
+ .as_deref()
+ .is_some_and(|detail| detail.contains(&delegation_id)),
+ "{claim_observation:?}"
+ );
+ let original = read_static_delegate_delivery_at(&root, &delegation_id)
+ .expect("read original safe-default delivery")
+ .expect("original safe-default delivery exists");
+ assert_eq!(
+ original.status,
+ StaticDelegateDeliveryStatus::ClaimedByParent
+ );
+ assert_eq!(
+ original
+ .structured_result
+ .as_ref()
+ .map(|result| result.contract_status.clone()),
+ Some(StaticDelegateContractStatus::NeedsRepair)
+ );
+ assert!(original
+ .structured_result
+ .as_ref()
+ .and_then(|result| result.error.as_deref())
+ .is_some_and(|error| error.contains("game-chat-safe-default-repair.v1")));
+ let repeated_route_plan = AgentRuntimeToolPlan {
+ thinking_summary: "重复提交既有资产路由".to_string(),
+ plan_update: None,
+ plan: Vec::new(),
+ actions: vec![AgentRuntimeToolAction {
+ tool: "agent.route_manifest".to_string(),
+ reason: Some("重复路由不应绕过返工门".to_string()),
+ input: serde_json::json!({
+ "strategy": GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING,
+ "missingAssetSlots": ["art-spec", "core-spritesheet"],
+ }),
+ }],
+ response: String::new(),
+ };
+ let liveness_error = validate_game_chat_code_safe_default_repair_liveness_at(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ &repeated_route_plan,
+ )
+ .expect_err("safe-default repair must reject repeated route/read plans");
+ assert!(
+ liveness_error.contains("必须立即推进唯一安全默认返工"),
+ "{liveness_error}"
+ );
+
+ let deterministic_repair =
+ game_chat_fast_path_plan_at(&root, &main, &main.current_task, unix_timestamp())
+ .expect("build trusted safe-default repair plan")
+ .expect("valid safe-default marker must produce a deterministic repair action");
+ assert_eq!(deterministic_repair.actions.len(), 1);
+ assert_eq!(deterministic_repair.actions[0].tool, "agent.delegate");
+ assert_eq!(
+ deterministic_repair.actions[0].input["agentId"].as_str(),
+ Some(original.target_agent_id.as_str())
+ );
+ assert_eq!(
+ deterministic_repair.actions[0].input["repairOfDelegationId"].as_str(),
+ Some(delegation_id.as_str())
+ );
+ validate_game_chat_code_safe_default_repair_liveness_at(
+ &root,
+ &main.agent_id,
+ &main.run_id,
+ &deterministic_repair,
+ )
+ .expect("deterministic same-contract repair must pass the liveness gate");
+
+ let repair_action_id = "game-chat-safe-default-repair-action";
+ let repair = observe_agent_runtime_agent_delegate(
+ &root,
+ "code-prototype",
+ &main.run_id,
+ Some(repair_action_id),
+ &deterministic_repair.actions[0].input,
+ );
+ assert_eq!(repair.status, "ok", "{repair:?}");
+ let repair_delegation_id = agent_runtime_delegation_id(
+ "code-prototype",
+ &main.run_id,
+ "art-director",
+ repair_action_id,
+ );
+ let repaired = read_static_delegate_delivery_at(&root, &repair_delegation_id)
+ .expect("read safe-default repair delivery")
+ .expect("safe-default repair delivery exists");
+ assert_eq!(
+ repaired.repair_of_delegation_id.as_deref(),
+ Some(delegation_id.as_str())
+ );
+ assert_eq!(repaired.target_agent_id, original.target_agent_id);
+ assert_eq!(repaired.acceptance_criteria, original.acceptance_criteria);
+ assert_eq!(repaired.expected_artifacts, original.expected_artifacts);
+ assert!(repaired.repair_of_delegation_id.is_some());
+
+ register_game_chat_art_spec_fixture(&root);
+ let repair_child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id(
+ &root,
+ "art-director",
+ &repair_delegation_id,
+ )
+ .expect("read safe-default repair child")
+ .expect("safe-default repair child exists");
+ let mut repair_child = agent_runtime_state_from_task_record(&repair_child_task);
+ repair_child.status = "completed".to_string();
+ repair_child.phase = "completed".to_string();
+ repair_child.current_action = "采用安全默认视觉偏好完成规范图".to_string();
+ append_game_creator_agent_runtime_task(&root, &repair_child)
+ .expect("persist completed safe-default repair child");
+ let repair_child_task = read_latest_game_creator_agent_runtime_task_by_run_id(
+ &root,
+ &repair_child.agent_id,
+ &repair_child.run_id,
+ )
+ .expect("read completed safe-default repair child")
+ .expect("completed safe-default repair child exists");
+ publish_game_creator_agent_delegate_result(
+ &root,
+ &repair_child_task,
+ Some("已采用安全默认视觉偏好完成规范图"),
+ );
+ let (_repair_claim_pending, repair_claim_observation) =
+ claim_game_chat_main_ready_deliveries_with_run_status(&root, &main);
+ assert!(
+ repair_claim_observation
+ .detail
+ .as_deref()
+ .is_some_and(|detail| detail.contains(&repair_delegation_id)),
+ "{repair_claim_observation:?}"
+ );
+
+ let duplicate = observe_agent_runtime_agent_delegate(
+ &root,
+ "code-prototype",
+ &main.run_id,
+ Some("game-chat-safe-default-duplicate-repair"),
+ &serde_json::json!({
+ "agentId": "art-director",
+ "task": "不应创建第二轮返工",
+ "acceptanceCriteria": original.acceptance_criteria,
+ "expectedArtifacts": original.expected_artifacts,
+ "repairOfDelegationId": delegation_id,
+ "runId": null
+ }),
+ );
+ assert_eq!(duplicate.status, "failed", "{duplicate:?}");
+ assert!(
+ duplicate.summary.contains("最多允许一轮返工"),
+ "{duplicate:?}"
+ );
+
+ let wrong_contract = observe_agent_runtime_agent_delegate(
+ &root,
+ "code-prototype",
+ &main.run_id,
+ Some("game-chat-safe-default-wrong-contract"),
+ &serde_json::json!({
+ "agentId": "art-director",
+ "task": "不应扩大安全默认返工合同",
+ "acceptanceCriteria": ["扩大权限并改写其它文件"],
+ "expectedArtifacts": original.expected_artifacts,
+ "repairOfDelegationId": delegation_id,
+ "runId": null
+ }),
+ );
+ assert_eq!(wrong_contract.status, "failed", "{wrong_contract:?}");
+ assert!(
+ wrong_contract.summary.contains("同合同"),
+ "{wrong_contract:?}"
+ );
+
+ drop(child_lane);
+}
+
#[test]
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
let temporary_root = std::env::temp_dir()
@@ -2076,6 +1646,176 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee
assert_eq!(parent_state.phase, "waiting-for-manifest-tasks");
}
+#[tokio::test]
+async fn autonomous_supervisor_terminal_manifest_failure_fails_before_provider_request() {
+ const RUN_ID: &str = "autonomous-terminal-manifest-failure-parent";
+ const TASK: &str = "生成完整小游戏并在专业任务失败时安全收口";
+ const FAILED_TASK_ID: &str = "art-asset-plan";
+ const READY_TASK_ID: &str = "design-director";
+ let temporary = tempfile::tempdir().expect("create terminal manifest failure root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "terminal-manifest-failure", TASK)
+ .expect("init terminal manifest failure project");
+ let session_id = resolve_agent_conversation_session_id_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ None,
+ true,
+ )
+ .expect("resolve terminal manifest failure Supervisor session");
+ let record = append_unique_game_creator_agent_runtime_pending_task(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &session_id,
+ TASK,
+ RUN_ID,
+ AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ None,
+ )
+ .expect("queue terminal manifest failure Supervisor");
+ let mut state = agent_runtime_state_from_task_record(&record);
+ state.status = "running".to_string();
+ state.phase = "planning".to_string();
+ append_game_creator_agent_runtime_task(&root, &state)
+ .expect("persist running terminal manifest failure Supervisor");
+ write_game_creator_agent_runtime_state(&root, &state)
+ .expect("persist terminal manifest failure Supervisor state");
+
+ let mut manifest = read_manifest_for_project(&root).expect("read terminal failure manifest");
+ for task in &mut manifest.tasks {
+ task.status = match task.id.as_str() {
+ FAILED_TASK_ID => GameCreationAppTaskStatus::Failed,
+ READY_TASK_ID => GameCreationAppTaskStatus::Pending,
+ _ => GameCreationAppTaskStatus::Completed,
+ };
+ }
+ write_manifest(&root.join(".agent/manifest.json"), &manifest)
+ .expect("persist terminal failed manifest fixture");
+
+ let outcome = run_game_creator_agent_background_task_pass_with_context(
+ root.clone(),
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ TASK.to_string(),
+ state,
+ AgentRuntimeContinuationContext::default(),
+ )
+ .await;
+ assert!(
+ matches!(outcome, AgentBackgroundTaskOutcome::Finished),
+ "terminal manifest failure must finish before requesting the Provider; outcome={outcome:?}"
+ );
+
+ let failed =
+ read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
+ .expect("read terminal failed Supervisor")
+ .state;
+ assert_eq!(failed.run_id, RUN_ID);
+ assert_eq!(failed.status, "failed");
+ assert_eq!(failed.phase, "failed");
+ assert!(failed.error.as_deref().is_some_and(|error| {
+ error.contains("自主构建 manifest 专业任务失败") && error.contains(FAILED_TASK_ID)
+ }));
+ let manifest = read_manifest_for_project(&root).expect("read terminal failed manifest");
+ assert_eq!(
+ manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == READY_TASK_ID)
+ .map(|task| task.status.clone()),
+ Some(GameCreationAppTaskStatus::Pending),
+ "a terminal manifest failure must not start another pending sibling"
+ );
+ let records = fs::read_to_string(root.join(".agent/agent.db"))
+ .expect("read terminal manifest failure audit");
+ assert!(!records.lines().any(|line| {
+ serde_json::from_str::(line)
+ .ok()
+ .is_some_and(|record| {
+ record["recordType"] == "agent.runtime.provider_request.lifecycle"
+ && record["runId"] == RUN_ID
+ })
+ }));
+}
+
+#[tokio::test]
+async fn autonomous_supervisor_waits_for_running_sibling_after_manifest_failure() {
+ const RUN_ID: &str = "autonomous-manifest-failure-running-sibling-parent";
+ const TASK: &str = "生成完整小游戏并等待在途专业任务安全收束";
+ const FAILED_TASK_ID: &str = "art-asset-plan";
+ const RUNNING_TASK_ID: &str = "audio-asset-plan";
+ let temporary = tempfile::tempdir().expect("create failed manifest running sibling root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "manifest-failure-running-sibling", TASK)
+ .expect("init failed manifest running sibling project");
+ let session_id = resolve_agent_conversation_session_id_at(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ None,
+ true,
+ )
+ .expect("resolve failed manifest running sibling Supervisor session");
+ let record = append_unique_game_creator_agent_runtime_pending_task(
+ &root,
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &session_id,
+ TASK,
+ RUN_ID,
+ AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
+ Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
+ None,
+ )
+ .expect("queue failed manifest running sibling Supervisor");
+ let mut state = agent_runtime_state_from_task_record(&record);
+ state.status = "running".to_string();
+ state.phase = "planning".to_string();
+ append_game_creator_agent_runtime_task(&root, &state)
+ .expect("persist failed manifest running sibling Supervisor");
+ write_game_creator_agent_runtime_state(&root, &state)
+ .expect("persist failed manifest running sibling Supervisor state");
+
+ let mut manifest = read_manifest_for_project(&root).expect("read failed sibling manifest");
+ for task in &mut manifest.tasks {
+ task.status = match task.id.as_str() {
+ FAILED_TASK_ID => GameCreationAppTaskStatus::Failed,
+ RUNNING_TASK_ID => GameCreationAppTaskStatus::Running,
+ _ => GameCreationAppTaskStatus::Completed,
+ };
+ }
+ write_manifest(&root.join(".agent/manifest.json"), &manifest)
+ .expect("persist failed manifest with running sibling fixture");
+
+ let outcome = run_game_creator_agent_background_task_pass_with_context(
+ root.clone(),
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
+ TASK.to_string(),
+ state,
+ AgentRuntimeContinuationContext::default(),
+ )
+ .await;
+ assert!(matches!(
+ outcome,
+ AgentBackgroundTaskOutcome::WaitingForManifestTasks
+ ));
+ let waiting =
+ read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
+ .expect("read Supervisor waiting for failed DAG sibling")
+ .state;
+ assert_eq!(waiting.run_id, RUN_ID);
+ assert_eq!(waiting.status, "running");
+ assert_eq!(waiting.phase, "waiting-for-manifest-tasks");
+ let records = fs::read_to_string(root.join(".agent/agent.db"))
+ .expect("read failed manifest running sibling audit");
+ assert!(!records.lines().any(|line| {
+ serde_json::from_str::(line)
+ .ok()
+ .is_some_and(|record| {
+ record["recordType"] == "agent.runtime.provider_request.lifecycle"
+ && record["runId"] == RUN_ID
+ })
+ }));
+}
+
fn prepare_autonomous_completion_evidence(
root: &Path,
state: &AgentRuntimeState,
@@ -2084,41 +1824,6 @@ fn prepare_autonomous_completion_evidence(
prepare_autonomous_completion_evidence_for_actor(root, state, state, expect_complete)
}
-fn start_full_dag_playtest_child_for_completion_fixture(
- root: &Path,
- parent_state: &AgentRuntimeState,
-) -> AgentRuntimeState {
- let manifest = read_manifest_for_project(root).expect("read completion fixture manifest");
- let task = manifest
- .tasks
- .iter()
- .find(|task| task.id == "preview-playtest")
- .expect("completion fixture preview-playtest task");
- let session_id = resolve_agent_conversation_session_id_at(root, "preview-playtest", None, true)
- .expect("resolve completion fixture preview-playtest session");
- let record = append_unique_game_creator_agent_runtime_pending_task(
- root,
- "preview-playtest",
- &session_id,
- &render_autonomous_manifest_ready_task_background_prompt(task),
- &autonomous_manifest_ready_task_run_id(&parent_state.run_id, "preview-playtest"),
- "agent-ready-task-scheduler",
- None,
- Some(&AgentRuntimeTaskLink {
- parent_agent_id: Some(parent_state.agent_id.clone()),
- parent_run_id: Some(parent_state.run_id.clone()),
- delegation_id: None,
- }),
- )
- .expect("queue completion fixture preview-playtest child");
- let mut state = agent_runtime_state_from_task_record(&record);
- state.status = "running".to_string();
- state.phase = "planning".to_string();
- append_game_creator_agent_runtime_task(root, &state)
- .expect("persist running completion fixture preview-playtest child");
- state
-}
-
fn prepare_autonomous_completion_evidence_for_actor(
root: &Path,
contract_state: &AgentRuntimeState,
@@ -2129,19 +1834,6 @@ fn prepare_autonomous_completion_evidence_for_actor(
read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id)
.expect("read autonomous completion contract")
.expect("autonomous completion contract exists");
- let root_binding = read_game_creator_agent_runtime_run_profile_binding(
- root,
- &contract_state.agent_id,
- &contract_state.run_id,
- )
- .expect("read completion fixture root binding")
- .expect("completion fixture root binding exists");
- let full_dag_playtest_state = matches!(
- root_binding.source.as_str(),
- AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
- )
- .then(|| start_full_dag_playtest_child_for_completion_fixture(root, contract_state));
- let playtest_state = full_dag_playtest_state.as_ref().unwrap_or(actor_state);
let revision = {
let latest = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
@@ -2237,11 +1929,11 @@ fn prepare_autonomous_completion_evidence_for_actor(
let evidence_root = root
.join(".agent/runtime/browser-validations")
.join(agent_runtime_confirmation_path_component(
- &playtest_state.agent_id,
+ &contract_state.agent_id,
"agent",
))
.join(agent_runtime_confirmation_path_component(
- &playtest_state.run_id,
+ &contract_state.run_id,
"run",
))
.join(revision.to_string());
@@ -2339,27 +2031,18 @@ fn prepare_autonomous_completion_evidence_for_actor(
input: serde_json::json!({}),
};
let action_fingerprint =
- agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task);
- let action_id =
- agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
+ agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task);
+ let action_id = agent_runtime_tool_action_id(&actor_state.run_id, 1, 0, 1, &action_fingerprint);
write_autonomous_playtest_receipt_at(
root,
&contract,
- playtest_state,
+ actor_state,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("write autonomous playtest receipt");
- if let Some(playtest_state) = full_dag_playtest_state.as_ref() {
- let mut completed = playtest_state.clone();
- completed.status = "completed".to_string();
- completed.phase = "completed".to_string();
- completed.current_action = "已完成独立桌面与移动试玩".to_string();
- append_game_creator_agent_runtime_task(root, &completed)
- .expect("complete fixture preview-playtest child after receipt");
- }
if expect_complete {
let blocker = autonomous_game_build_completion_blocker_at_locked(root, actor_state);
assert!(
@@ -2370,17 +2053,162 @@ fn prepare_autonomous_completion_evidence_for_actor(
revision
}
+fn prepare_autonomous_playtest_evidence_for_actor_at_revision(
+ root: &Path,
+ contract_state: &AgentRuntimeState,
+ actor_state: &AgentRuntimeState,
+ revision: u64,
+) -> BrowserValidationResult {
+ let contract =
+ read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id)
+ .expect("read autonomous completion contract for playtest fixture")
+ .expect("autonomous completion contract exists for playtest fixture");
+ bind_supervisor_collaboration_policy_snapshot_at(
+ root,
+ &contract_state.agent_id,
+ &contract_state.run_id,
+ &SupervisorCollaborationPolicy::default(),
+ "legacy-current-project-policy",
+ )
+ .expect("bind deterministic game-chat collaboration policy");
+
+ let evidence_root = root
+ .join(".agent/runtime/browser-validations")
+ .join(agent_runtime_confirmation_path_component(
+ &contract_state.agent_id,
+ "agent",
+ ))
+ .join(agent_runtime_confirmation_path_component(
+ &contract_state.run_id,
+ "run",
+ ))
+ .join(revision.to_string());
+ fs::create_dir_all(&evidence_root).expect("create deterministic browser evidence root");
+ let screenshots = [
+ evidence_root.join("desktop.png"),
+ evidence_root.join("mobile.png"),
+ ];
+ for screenshot in &screenshots {
+ fs::write(screenshot, b"\x89PNG\r\n\x1a\nfixture")
+ .expect("write deterministic browser screenshot fixture");
+ }
+ let viewport = |viewport, screenshot_path| BrowserViewportValidationResult {
+ viewport,
+ width: if viewport == BrowserValidationViewport::Desktop {
+ 1280
+ } else {
+ 390
+ },
+ height: if viewport == BrowserValidationViewport::Desktop {
+ 720
+ } else {
+ 844
+ },
+ final_url: "http://127.0.0.1:34567/".to_string(),
+ title: "自主试玩测试".to_string(),
+ ready_state: "complete".to_string(),
+ visible_text_summary: "可试玩项目".to_string(),
+ visible_text_character_count: 5,
+ dom_character_count: 100,
+ expected_text: Vec::new(),
+ console_errors: Vec::new(),
+ console_warnings: Vec::new(),
+ exceptions: Vec::new(),
+ failed_requests: Vec::new(),
+ canvases: Vec::new(),
+ blocked_popup_count: 0,
+ blocked_dialog_count: 0,
+ blocked_download_count: 0,
+ blocked_permission_count: 0,
+ blocked_service_worker_count: 0,
+ screenshot_path,
+ passed: true,
+ diagnostics: Vec::new(),
+ };
+ let scenario = contract.playtest_scenario;
+ let result = BrowserValidationResult {
+ schema_version: "browser-validation.v1".to_string(),
+ url: "http://127.0.0.1:34567/".to_string(),
+ browser: BrowserIdentity {
+ kind: DiscoveredBrowserKind::Chrome,
+ product: "test-browser".to_string(),
+ protocol_version: "1".to_string(),
+ },
+ passed: true,
+ viewport_results: vec![
+ viewport(BrowserValidationViewport::Desktop, screenshots[0].clone()),
+ viewport(BrowserValidationViewport::Mobile, screenshots[1].clone()),
+ ],
+ playtest: Some(BrowserPlaytestResult {
+ scenario,
+ scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario),
+ passed: true,
+ initial_sequence: Some(1),
+ initial_phase: Some(BrowserPlaytestPhase::Ready),
+ initial_level: Some(1),
+ final_sequence: Some(8),
+ final_phase: Some(BrowserPlaytestPhase::Playing),
+ final_level: Some(2),
+ assertions: scenario
+ .assertion_names()
+ .iter()
+ .map(|name| BrowserPlaytestAssertion {
+ name: (*name).to_string(),
+ passed: true,
+ })
+ .collect(),
+ diagnostics: Vec::new(),
+ }),
+ diagnostics: Vec::new(),
+ evidence: BrowserValidationEvidencePaths {
+ root: evidence_root.clone(),
+ report_path: evidence_root.join("validation.json"),
+ },
+ completed_at_unix_ms: 1,
+ };
+ fs::write(
+ &result.evidence.report_path,
+ serde_json::to_vec_pretty(&result).expect("serialize deterministic browser evidence"),
+ )
+ .expect("write deterministic browser evidence report");
+ let action = AgentRuntimeToolAction {
+ tool: "preview.validate".to_string(),
+ reason: Some("验证真实可玩闭环".to_string()),
+ input: serde_json::json!({}),
+ };
+ let action_fingerprint =
+ agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task);
+ let action_id = agent_runtime_tool_action_id(
+ &actor_state.run_id,
+ actor_state.loop_iteration.max(1),
+ 3,
+ 1,
+ &action_fingerprint,
+ );
+ write_autonomous_playtest_receipt_at(
+ root,
+ &contract,
+ actor_state,
+ &action_id,
+ &action_fingerprint,
+ revision,
+ &result,
+ )
+ .expect("write deterministic autonomous playtest receipt");
+ result
+}
+
#[test]
fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_delivery() {
let temporary = tempfile::tempdir().expect("create legacy game-chat Tetris root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "legacy-game-chat-tetris", "水晶俄罗斯方块")
.expect("init project");
- let (root_state, mut main_state) = queue_game_chat_fast_path_child(
+ let (root_state, preview_state) = queue_game_chat_fast_path_child(
&root,
"legacy-game-chat-tetris-root",
"做一个俄罗斯方块,包含旋转、重力下落、锁定和消行",
- "code-prototype",
+ "preview-playtest",
);
let mut legacy_contract =
read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id)
@@ -2399,24 +2227,30 @@ fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_deli
&legacy_contract,
)
.expect("persist legacy Generic Tetris contract");
- prepare_autonomous_completion_evidence_for_actor(&root, &root_state, &main_state, false);
- main_state.status = "running".to_string();
- main_state.phase = "planning".to_string();
- persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main_state);
+ prepare_autonomous_completion_evidence(&root, &root_state, false);
- let migrated = autonomous_playtest_execution_contract_for_state_at(&root, &main_state)
- .expect("evaluate migrated code-prototype preview execution contract")
- .expect("code-prototype inherits the migrated root contract");
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read root binding")
+ .expect("root binding exists")
+ .bound_at;
+ let plan =
+ game_chat_fast_path_plan_at(&root, &preview_state, &preview_state.current_task, bound_at)
+ .expect("evaluate migrated preview-playtest fast path")
+ .expect("preview-playtest emits a deterministic plan");
+ assert_eq!(plan.actions.len(), 1, "unexpected plan: {plan:?}");
+ assert_eq!(plan.actions[0].tool, "preview.validate");
+ let migrated =
+ read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id)
+ .expect("read migrated root contract")
+ .expect("migrated root contract exists");
assert_eq!(
migrated.playtest_scenario,
BrowserPlaytestScenario::TetrisV1
);
- assert!(
- read_autonomous_playtest_receipt(&root, &migrated)
- .expect("legacy Generic receipt becomes recoverably stale")
- .is_none(),
- "the Generic receipt must not satisfy the migrated Tetris contract"
- );
}
#[test]
@@ -2432,9 +2266,37 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf
assert!(!autonomous_manifest_ready_task_requires_visual_asset(
"art-asset-plan"
));
+ let art_director = new_game_creation_app_seed_tasks()
+ .into_iter()
+ .find(|task| task.id == "art-director")
+ .expect("art-director seed task");
+ let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director);
+ let read_only_delivery =
+ agent_runtime_task_requires_read_only_delivery("art-director", &task_text);
+ assert!(read_only_delivery);
+ let forbidden_file_write = AgentRuntimeToolPlan {
+ actions: vec![AgentRuntimeToolAction {
+ tool: "file.write".to_string(),
+ reason: Some("无 External Editor API 时不得写入项目".to_string()),
+ input: serde_json::json!({
+ "path": "assets/art-spec.png",
+ "content": "not-a-canvas-asset"
+ }),
+ }],
+ ..AgentRuntimeToolPlan::default()
+ };
+ let error = validate_agent_runtime_autonomous_read_only_delivery_plan(
+ "art-director",
+ read_only_delivery,
+ &forbidden_file_write,
+ )
+ .expect_err("art-director without External Editor API must remain read-only");
+ assert!(error.contains("自主构建只读专业 Agent 禁止执行写入或副作用动作"));
}
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "visual-ready-task-user",
+ "visual-ready-task-test-key",
+ "https://dev.genarrative.world",
);
assert!(autonomous_manifest_ready_task_requires_visual_asset(
"art-director"
@@ -2448,12 +2310,26 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf
assert!(!autonomous_manifest_ready_task_requires_visual_asset(
"code-prototype"
));
+ let art_director = new_game_creation_app_seed_tasks()
+ .into_iter()
+ .find(|task| task.id == "art-director")
+ .expect("art-director seed task");
+ let task_text = render_autonomous_manifest_ready_task_background_prompt(&art_director);
+ assert!(task_text.contains("非只读视觉规范生成任务"));
+ assert!(task_text.contains("唯一允许的项目写动作是 canvas.asset_generate"));
+ assert!(task_text.contains("assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1)"));
+ assert!(!agent_runtime_task_requires_read_only_delivery(
+ "art-director",
+ &task_text
+ ));
}
#[test]
fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() {
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "game-chat-fast-art-user",
+ "game-chat-fast-art-key",
+ "https://dev.genarrative.world",
);
let temporary = tempfile::tempdir().expect("create game-chat art root");
let root = temporary.path().join("project");
@@ -2497,8 +2373,10 @@ fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planni
#[test]
fn game_chat_art_asset_plan_uses_deterministic_icon_spritesheet_generation() {
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"game-chat-fast-spritesheet-key"}}"#.to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "game-chat-fast-spritesheet-user",
+ "game-chat-fast-spritesheet-key",
+ "https://dev.genarrative.world",
);
let temporary = tempfile::tempdir().expect("create game-chat spritesheet root");
let root = temporary.path().join("project");
@@ -2590,6 +2468,10 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() {
gate.last_verification_tool = Some("game.static_smoke".to_string());
gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
gate.static_smoke_verified_revision = Some(revision.revision);
+ gate.static_smoke_verified_game_index_sha256 =
+ fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH))
+ .ok()
+ .map(|bytes| format!("{:x}", Sha256::digest(bytes)));
write_game_creator_agent_runtime_verification_gate(&root, &gate)
.expect("write code verification gate");
child_state.applied_steer_cursor = 2;
@@ -2707,7 +2589,11 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() {
.expect("evaluate repaired code delivery")
.expect("fully repaired code may use deterministic delivery");
assert_eq!(playtest.actions.len(), 1);
- assert_eq!(playtest.actions[0].tool, "preview.validate");
+ assert_eq!(playtest.actions[0].tool, "command.run_limited");
+ assert_eq!(
+ playtest.actions[0].input.get("commandId"),
+ Some(&serde_json::json!("game.static_smoke"))
+ );
assert!(playtest.response.is_empty());
}
@@ -2744,6 +2630,10 @@ fn game_chat_code_failed_patch_revision_does_not_count_as_owned_mutation() {
gate.last_verification_tool = Some("game.static_smoke".to_string());
gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
gate.static_smoke_verified_revision = Some(revision.revision);
+ gate.static_smoke_verified_game_index_sha256 =
+ fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH))
+ .ok()
+ .map(|bytes| format!("{:x}", Sha256::digest(bytes)));
write_game_creator_agent_runtime_verification_gate(&root, &gate)
.expect("write failed patch verification gate");
let prior_action = AgentRuntimeToolAction {
@@ -2874,6 +2764,10 @@ fn game_chat_code_cannot_borrow_successful_mutation_receipt_from_another_run() {
gate.last_verification_tool = Some("game.static_smoke".to_string());
gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
gate.static_smoke_verified_revision = Some(revision.revision);
+ gate.static_smoke_verified_game_index_sha256 =
+ fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH))
+ .ok()
+ .map(|bytes| format!("{:x}", Sha256::digest(bytes)));
write_game_creator_agent_runtime_verification_gate(&root, &gate)
.expect("write current verification gate");
let action = AgentRuntimeToolAction {
@@ -3403,14 +3297,155 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_
);
}
+#[test]
+fn game_chat_code_second_loop_waits_for_provider_before_asset_route() {
+ let temporary = tempfile::tempdir().expect("create unrouted game-chat code root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "game-chat-unrouted-code", "制作星河收集小游戏")
+ .expect("init unrouted game-chat code project");
+ register_game_chat_art_spec_fixture(&root);
+ register_game_chat_art_spritesheet_fixture(&root);
+ fs::write(
+ root.join("assets/manifest.art.json"),
+ game_chat_fast_path_art_manifest_content(),
+ )
+ .expect("write unrouted complete art manifest");
+ let (root_state, mut code_state) = queue_game_chat_fast_path_child(
+ &root,
+ "game-chat-unrouted-code-root",
+ "制作星河收集小游戏",
+ "code-prototype",
+ );
+ code_state.loop_iteration = 2;
+ persist_game_chat_main_asset_audit(&root, &mut code_state);
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read unrouted game-chat root binding")
+ .expect("unrouted game-chat root binding exists")
+ .bound_at;
+
+ assert!(
+ game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
+ .expect("evaluate unrouted second loop")
+ .is_none(),
+ "asset.list 后尚未形成资产路由时,第二轮必须交给 Provider 决策与委派"
+ );
+}
+
+#[test]
+fn game_chat_code_second_loop_waits_for_delegated_visual_contract() {
+ let temporary = tempfile::tempdir().expect("create routed game-chat code root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "game-chat-routed-code", "制作星河收集小游戏")
+ .expect("init routed game-chat code project");
+ let (root_state, mut code_state) = queue_game_chat_fast_path_child(
+ &root,
+ "game-chat-routed-code-root",
+ "制作星河收集小游戏",
+ "code-prototype",
+ );
+ code_state.loop_iteration = 2;
+ persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state);
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read routed game-chat root binding")
+ .expect("routed game-chat root binding exists")
+ .bound_at;
+
+ assert!(
+ game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
+ .expect("evaluate routed second loop without art")
+ .is_none(),
+ "资产路由已持久化但美术 child 尚未交付时,第二轮必须继续交给 Provider 执行委派"
+ );
+}
+
+#[test]
+fn game_chat_code_second_loop_allows_fallback_after_visual_contract_is_ready() {
+ let temporary = tempfile::tempdir().expect("create ready game-chat code root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "game-chat-ready-code", "制作星河收集小游戏")
+ .expect("init ready game-chat code project");
+ register_game_chat_art_spec_fixture(&root);
+ register_game_chat_art_spritesheet_fixture(&root);
+ fs::write(
+ root.join("assets/manifest.art.json"),
+ game_chat_fast_path_art_manifest_content(),
+ )
+ .expect("write ready complete art manifest");
+ let (root_state, mut code_state) = queue_game_chat_fast_path_child(
+ &root,
+ "game-chat-ready-code-root",
+ "制作星河收集小游戏",
+ "code-prototype",
+ );
+ code_state.loop_iteration = 2;
+ persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state);
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read ready game-chat root binding")
+ .expect("ready game-chat root binding exists")
+ .bound_at;
+
+ let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
+ .expect("evaluate ready second loop")
+ .expect("verified visual contract may use deterministic fallback");
+ assert_eq!(plan.actions.len(), 1);
+ assert_eq!(plan.actions[0].tool, "file.write");
+ assert_eq!(
+ plan.actions[0].input.get("path"),
+ Some(&serde_json::json!(AGENT_RUNTIME_GAME_INDEX_PATH))
+ );
+}
+
+#[test]
+fn game_chat_code_soft_budget_still_fails_closed_without_visual_contract() {
+ let temporary = tempfile::tempdir().expect("create budgeted game-chat code root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "game-chat-budgeted-code", "制作星河收集小游戏")
+ .expect("init budgeted game-chat code project");
+ let (root_state, mut code_state) = queue_game_chat_fast_path_child(
+ &root,
+ "game-chat-budgeted-code-root",
+ "制作星河收集小游戏",
+ "code-prototype",
+ );
+ persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state);
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read budgeted game-chat root binding")
+ .expect("budgeted game-chat root binding exists")
+ .bound_at;
+
+ let error = game_chat_fast_path_plan_at(
+ &root,
+ &code_state,
+ &code_state.current_task,
+ bound_at.saturating_add(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS),
+ )
+ .expect_err("soft budget must still fail closed when visual contracts are missing");
+ assert!(error.contains("assets/art-spritesheet.png"));
+}
+
#[test]
fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_delivery() {
let temporary = tempfile::tempdir().expect("create existing game code root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块")
.expect("init existing game project");
- let existing =
- " ";
+ let existing = "目标:旋转方块消除整行获得胜利;堆满后失败,按 R 重新开始。
";
fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing)
.expect("write existing non-placeholder game");
let (root_state, mut code_state) = queue_game_chat_fast_path_child(
@@ -3498,6 +3533,584 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del
assert_eq!(code_gate.verified_revision, Some(1));
}
+#[test]
+fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_intervention() {
+ const ROOT_RUN_ID: &str = "game-chat-deterministic-repair-root";
+ const TASK: &str = "生成一版完整可玩的彩虹餐厅经营小游戏,全程自主修复并完成";
+ let _config_guard = crate::tests::write_test_local_config("{}".to_string());
+ let temporary = tempfile::tempdir().expect("create deterministic game-chat repair root");
+ let root = temporary.path().join("project");
+ init_local_game_project_at(&root, "game-chat-deterministic-repair", TASK)
+ .expect("init deterministic game-chat repair project");
+
+ let seed_tasks =
+ autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE);
+ assert_eq!(
+ seed_tasks
+ .iter()
+ .map(|task| task.id.as_str())
+ .collect::>(),
+ vec!["code-prototype"],
+ "trusted game-chat must persist one single-main manifest lane"
+ );
+ let (mut root_state, mut code_state) =
+ queue_game_chat_fast_path_child(&root, ROOT_RUN_ID, TASK, "code-prototype");
+ crate::tests::freeze_test_root_goal_contract_at(&root, ROOT_RUN_ID);
+ assert_eq!(root_state.source, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE);
+ assert_eq!(
+ root_state.run_profile,
+ AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
+ );
+ assert_eq!(code_state.agent_id, "code-prototype");
+ assert_eq!(code_state.source, "agent-ready-task-scheduler");
+ assert_eq!(
+ code_state.parent_agent_id.as_deref(),
+ Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
+ );
+ assert_eq!(code_state.parent_run_id.as_deref(), Some(ROOT_RUN_ID));
+ assert_eq!(
+ code_state.run_id,
+ autonomous_manifest_ready_task_run_id(ROOT_RUN_ID, "code-prototype")
+ );
+ register_game_chat_art_spec_fixture(&root);
+ register_game_chat_art_spritesheet_fixture(&root);
+ let art_manifest = read_manifest_for_project(&root).expect("read registered art manifest");
+ validate_manifest_required_visual_asset(&root, &art_manifest, "art-director")
+ .expect("deterministic art spec is reusable");
+ validate_manifest_required_visual_asset(&root, &art_manifest, "art-asset-plan")
+ .expect("deterministic spritesheet is reusable");
+ write_local_project_file_at(
+ &root,
+ "assets/manifest.art.json",
+ &game_chat_fast_path_art_manifest_content(),
+ )
+ .expect("write reusable deterministic art manifest before audit");
+ code_state.status = "running".to_string();
+ code_state.phase = "planning".to_string();
+ code_state.loop_iteration = 1;
+ append_game_creator_agent_runtime_task(&root, &code_state)
+ .expect("persist running deterministic code child");
+ write_game_creator_agent_runtime_state(&root, &code_state)
+ .expect("persist deterministic code runtime");
+ persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state);
+
+ let playable_html = render_game_chat_fast_path_html("彩虹餐厅经营挑战");
+ let invalid_html = playable_html.replacen(
+ " stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);",
+ " function terminalRepairProbe() {} terminalRepairProbe()const terminalPhases = ['won', 'lost'];\n stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);",
+ 1,
+ );
+ assert_ne!(
+ invalid_html, playable_html,
+ "invalid JavaScript fixture marker"
+ );
+ assert!(invalid_html.contains("terminalRepairProbe()const terminalPhases"));
+ let initial_write = AgentRuntimeToolAction {
+ tool: "file.write".to_string(),
+ reason: Some("生成首版可试玩游戏入口".to_string()),
+ input: serde_json::json!({
+ "path": AGENT_RUNTIME_GAME_INDEX_PATH,
+ "content": invalid_html,
+ }),
+ };
+ let initial_write_fingerprint =
+ agent_runtime_tool_action_fingerprint(&initial_write, &code_state.current_task);
+ let initial_write_id = agent_runtime_tool_action_id(
+ &code_state.run_id,
+ code_state.loop_iteration,
+ 0,
+ 1,
+ &initial_write_fingerprint,
+ );
+ let initial_write_observation = observe_agent_runtime_file_write(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ &initial_write,
+ &initial_write_fingerprint,
+ None,
+ );
+ assert_eq!(initial_write_observation.status, "ok");
+ let initial_write_task = code_state.current_task.clone();
+ append_agent_runtime_tool_call_record(
+ &root,
+ &mut code_state,
+ &initial_write_task,
+ &initial_write,
+ &initial_write_observation,
+ Some(&initial_write_id),
+ Some(&initial_write_fingerprint),
+ );
+ append_agent_runtime_action_receipt(
+ &root,
+ &code_state,
+ &initial_write_id,
+ &initial_write_fingerprint,
+ "file.write",
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ None,
+ &initial_write_observation,
+ )
+ .expect("persist initial game write receipt");
+
+ let failed_smoke = AgentRuntimeToolAction {
+ tool: "command.run_limited".to_string(),
+ reason: Some("检查首版游戏入口".to_string()),
+ input: serde_json::json!({ "commandId": "game.static_smoke" }),
+ };
+ let failed_smoke_fingerprint =
+ agent_runtime_tool_action_fingerprint(&failed_smoke, &code_state.current_task);
+ let failed_smoke_id = agent_runtime_tool_action_id(
+ &code_state.run_id,
+ code_state.loop_iteration,
+ 1,
+ 1,
+ &failed_smoke_fingerprint,
+ );
+ let failed_smoke_observation = observe_agent_runtime_limited_command(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ &failed_smoke.input,
+ );
+ assert_eq!(failed_smoke_observation.status, "failed");
+ let failed_smoke_detail = failed_smoke_observation
+ .detail
+ .as_deref()
+ .expect("failed smoke diagnostic");
+ assert!(
+ failed_smoke_detail.contains("不是有效 JavaScript"),
+ "{failed_smoke_detail}"
+ );
+ assert!(
+ !failed_smoke_detail.contains("可渲染画布"),
+ "JavaScript syntax must fail before surface checks: {failed_smoke_detail}"
+ );
+ let failed_smoke_task = code_state.current_task.clone();
+ append_agent_runtime_tool_call_record(
+ &root,
+ &mut code_state,
+ &failed_smoke_task,
+ &failed_smoke,
+ &failed_smoke_observation,
+ Some(&failed_smoke_id),
+ Some(&failed_smoke_fingerprint),
+ );
+ append_agent_runtime_action_receipt(
+ &root,
+ &code_state,
+ &failed_smoke_id,
+ &failed_smoke_fingerprint,
+ "command.run_limited",
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ None,
+ &failed_smoke_observation,
+ )
+ .expect("persist failed static smoke receipt");
+
+ update_agent_runtime_plan_steps(
+ &mut code_state,
+ vec!["记录开发者投递的后台任务".to_string()],
+ );
+ activate_agent_runtime_plan_step(&mut code_state, 0, "执行 game.static_smoke");
+ complete_agent_runtime_active_plan_step(
+ &mut code_state,
+ "failed",
+ &failed_smoke_observation.summary(),
+ );
+ assert_eq!(code_state.plan_revision, 0);
+ assert_eq!(
+ code_state.plan_steps[0].status,
+ AGENT_RUNTIME_PLAN_STATUS_FAILED
+ );
+
+ apply_agent_runtime_plan_update(
+ &mut code_state,
+ &AgentRuntimePlanUpdate {
+ explanation: "首版 smoke 已返回可修复诊断,保持同一主 Run 继续修复与复验".to_string(),
+ steps: vec![
+ AgentRuntimePlanUpdateStep {
+ step: "完成资产审计与缺口路由".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(),
+ },
+ AgentRuntimePlanUpdateStep {
+ step: "写入可运行游戏 scaffold".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(),
+ },
+ AgentRuntimePlanUpdateStep {
+ step: "修复静态 smoke 阻断".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(),
+ },
+ AgentRuntimePlanUpdateStep {
+ step: "重新执行静态 smoke".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
+ },
+ AgentRuntimePlanUpdateStep {
+ step: "完成浏览器试玩与视觉检查".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
+ },
+ AgentRuntimePlanUpdateStep {
+ step: "向父 Agent 交付证据回执".to_string(),
+ status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
+ },
+ ],
+ },
+ )
+ .expect("replace failed legacy scaffold with first structured repair plan");
+ assert_eq!(code_state.plan_revision, 1);
+ assert!(code_state
+ .plan_steps
+ .iter()
+ .all(|step| step.status != AGENT_RUNTIME_PLAN_STATUS_FAILED));
+ assert!(code_state
+ .plan_steps
+ .iter()
+ .all(|step| step.title != "记录开发者投递的后台任务"));
+
+ let (history, count, truncated, output_truncated) = read_agent_runtime_action_history(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ &serde_json::json!({
+ "runId": code_state.run_id,
+ "actionId": failed_smoke_id,
+ "tool": "command.run_limited",
+ "status": "failed",
+ "limit": 1,
+ }),
+ )
+ .expect("read owner-visible failed smoke action history");
+ assert_eq!(count, 1);
+ assert!(!truncated);
+ assert!(!output_truncated);
+ let history: serde_json::Value =
+ serde_json::from_str(&history).expect("parse failed smoke history");
+ let history_item = &history["actions"][0];
+ assert_eq!(history_item["actionId"], failed_smoke_id);
+ let diagnostic: serde_json::Value = serde_json::from_str(
+ history_item["safeDetail"]
+ .as_str()
+ .expect("owner-visible structured smoke detail"),
+ )
+ .expect("parse owner-visible structured smoke detail");
+ assert_eq!(diagnostic["failureCode"], "static-smoke-failed");
+ assert_eq!(diagnostic["check"], "javascript-syntax");
+ assert_eq!(diagnostic["path"], AGENT_RUNTIME_GAME_INDEX_PATH);
+ assert!(diagnostic["diagnostic"]
+ .as_str()
+ .is_some_and(|value| value.contains("不是有效 JavaScript")));
+
+ code_state.loop_iteration = 2;
+ let repair = AgentRuntimeToolAction {
+ tool: "file.patch".to_string(),
+ reason: Some("根据结构化 JavaScript 语法诊断修复当前入口".to_string()),
+ input: serde_json::json!({
+ "path": AGENT_RUNTIME_GAME_INDEX_PATH,
+ "oldText": "terminalRepairProbe()const terminalPhases",
+ "newText": "terminalRepairProbe();const terminalPhases",
+ "expectedReplacements": 1,
+ }),
+ };
+ let repair_fingerprint =
+ agent_runtime_tool_action_fingerprint(&repair, &code_state.current_task);
+ let repair_id = agent_runtime_tool_action_id(
+ &code_state.run_id,
+ code_state.loop_iteration,
+ 0,
+ 1,
+ &repair_fingerprint,
+ );
+ let repair_observation = observe_agent_runtime_file_patch(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ &repair,
+ &repair_fingerprint,
+ None,
+ );
+ assert_eq!(repair_observation.status, "ok", "{repair_observation:?}");
+ let repair_task = code_state.current_task.clone();
+ append_agent_runtime_tool_call_record(
+ &root,
+ &mut code_state,
+ &repair_task,
+ &repair,
+ &repair_observation,
+ Some(&repair_id),
+ Some(&repair_fingerprint),
+ );
+ append_agent_runtime_action_receipt(
+ &root,
+ &code_state,
+ &repair_id,
+ &repair_fingerprint,
+ "file.patch",
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ None,
+ &repair_observation,
+ )
+ .expect("persist same-run repair receipt");
+
+ let bound_at = read_game_creator_agent_runtime_run_profile_binding(
+ &root,
+ &root_state.agent_id,
+ &root_state.run_id,
+ )
+ .expect("read deterministic game-chat root binding")
+ .expect("deterministic game-chat root binding exists")
+ .bound_at;
+ let automatic_recheck =
+ game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
+ .expect("evaluate repaired current revision")
+ .expect("same-run repair must automatically recheck current revision");
+ assert_eq!(automatic_recheck.actions.len(), 1);
+ assert_eq!(automatic_recheck.actions[0].tool, "command.run_limited");
+ assert_eq!(
+ automatic_recheck.actions[0].input.get("commandId"),
+ Some(&serde_json::json!("game.static_smoke"))
+ );
+ assert!(automatic_recheck.plan_update.is_none());
+ assert!(automatic_recheck.response.is_empty());
+
+ let repaired_smoke = AgentRuntimeToolAction {
+ tool: "command.run_limited".to_string(),
+ reason: Some("重新验证已修复的当前 revision".to_string()),
+ input: serde_json::json!({ "commandId": "game.static_smoke" }),
+ };
+ let repaired_smoke_fingerprint =
+ agent_runtime_tool_action_fingerprint(&repaired_smoke, &code_state.current_task);
+ let repaired_smoke_id = agent_runtime_tool_action_id(
+ &code_state.run_id,
+ code_state.loop_iteration,
+ 1,
+ 1,
+ &repaired_smoke_fingerprint,
+ );
+ let repaired_smoke_observation = observe_agent_runtime_limited_command(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ &repaired_smoke.input,
+ );
+ assert_eq!(
+ repaired_smoke_observation.status, "ok",
+ "{repaired_smoke_observation:?}"
+ );
+ let repaired_smoke_task = code_state.current_task.clone();
+ append_agent_runtime_tool_call_record(
+ &root,
+ &mut code_state,
+ &repaired_smoke_task,
+ &repaired_smoke,
+ &repaired_smoke_observation,
+ Some(&repaired_smoke_id),
+ Some(&repaired_smoke_fingerprint),
+ );
+ append_agent_runtime_action_receipt(
+ &root,
+ &code_state,
+ &repaired_smoke_id,
+ &repaired_smoke_fingerprint,
+ "command.run_limited",
+ AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
+ None,
+ &repaired_smoke_observation,
+ )
+ .expect("persist repaired static smoke receipt");
+ let revision = read_game_creator_agent_runtime_project_revision(&root)
+ .expect("read repaired project revision")
+ .revision;
+ let code_gate = read_game_creator_agent_runtime_verification_gate(
+ &root,
+ &code_state.agent_id,
+ &code_state.run_id,
+ )
+ .expect("read repaired code verification gate");
+ assert_eq!(code_gate.mutation_revision, Some(revision));
+ assert_eq!(code_gate.verified_revision, Some(revision));
+ assert_eq!(code_gate.static_smoke_verified_revision, Some(revision));
+ assert_eq!(code_gate.last_mutation_tool.as_deref(), Some("file.patch"));
+
+ for (path, content) in [
+ ("memory/project.md", "# 项目记忆\n\n正式约束。\n"),
+ ("game/game_design.md", "# 游戏设计\n\n核心循环。\n"),
+ ("game/balance.json", r#"{"lives":3,"speed":1}"#),
+ ("assets/manifest.audio.json", r#"{"bgm":[],"sfx":[]}"#),
+ ("exports/README.md", "# 发布说明\n\n可试玩。\n"),
+ ] {
+ write_local_project_file_at(&root, path, content)
+ .unwrap_or_else(|error| panic!("write deterministic artifact {path}: {error}"));
+ }
+ let browser_result = prepare_autonomous_playtest_evidence_for_actor_at_revision(
+ &root,
+ &root_state,
+ &code_state,
+ revision,
+ );
+ assert!(browser_result.passed);
+ assert_eq!(browser_result.viewport_results.len(), 2);
+ assert!(browser_result
+ .viewport_results
+ .iter()
+ .any(
+ |viewport| viewport.viewport == BrowserValidationViewport::Desktop && viewport.passed
+ ));
+ assert!(browser_result
+ .viewport_results
+ .iter()
+ .any(|viewport| viewport.viewport == BrowserValidationViewport::Mobile && viewport.passed));
+ let code_completion_blocker =
+ autonomous_game_build_completion_blocker_at_locked(&root, &code_state);
+ assert!(
+ code_completion_blocker.is_none(),
+ "deterministic code completion blocker: {code_completion_blocker:?}"
+ );
+
+ let validated_game_index =
+ fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)).expect("snapshot validated game entry");
+ fs::write(
+ root.join(AGENT_RUNTIME_GAME_INDEX_PATH),
+ " ",
+ )
+ .expect("mutate game entry without advancing durable revision");
+ let stale_receipt_blocker =
+ autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
+ .expect("same-revision stale playtest receipt must block completion");
+ assert!(
+ stale_receipt_blocker.summary.contains("game.static_smoke"),
+ "unexpected stale receipt blocker: {stale_receipt_blocker:?}"
+ );
+ fs::write(
+ root.join(AGENT_RUNTIME_GAME_INDEX_PATH),
+ validated_game_index,
+ )
+ .expect("restore validated game entry");
+ assert!(
+ autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(),
+ "restored validated entry must satisfy completion gate"
+ );
+
+ let completed_code = finish_game_creator_agent_runtime_turn_at(
+ &root,
+ code_state,
+ &format!("首版已完成,并通过 revision {revision} 的静态检查与双视口试玩。"),
+ )
+ .expect("finish deterministic code-prototype run");
+ update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
+ .expect("restore running manifest projection before deterministic terminal projection");
+ assert!(
+ project_autonomous_manifest_ready_task_terminal_at(&root, &completed_code)
+ .expect("project deterministic code-prototype completion")
+ );
+ let root_stale_receipt_blocker =
+ autonomous_game_build_completion_blocker_at_locked(&root, &root_state);
+ assert!(
+ root_stale_receipt_blocker.is_none(),
+ "root must accept the restored current entry: {root_stale_receipt_blocker:?}"
+ );
+ crate::tests::pass_test_root_acceptance_graph_at(&root, &root_state);
+ let mut provider_plan = AgentRuntimeToolPlan {
+ actions: vec![AgentRuntimeToolAction {
+ tool: "agent.run_status".to_string(),
+ reason: Some("模型原本仍想继续轮询".to_string()),
+ input: serde_json::json!({}),
+ }],
+ ..AgentRuntimeToolPlan::default()
+ };
+ let convergence = prepare_game_chat_single_round_convergence_at(
+ &root,
+ &mut root_state,
+ &mut provider_plan,
+ unix_timestamp(),
+ )
+ .expect("prepare deterministic root convergence")
+ .expect("fully verified game-chat root must converge without another Provider plan");
+ assert_eq!(convergence.1, revision);
+ assert!(provider_plan.actions.is_empty());
+ let completed_root =
+ finish_game_creator_agent_runtime_turn_at(&root, root_state, &convergence.0)
+ .expect("finish deterministic game-chat root once");
+ assert_eq!(completed_root.phase, "completed");
+
+ let logical_code_runs = latest_game_creator_agent_runtime_tasks(
+ read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path(
+ &root,
+ "code-prototype",
+ ))
+ .expect("read deterministic code journal"),
+ )
+ .into_iter()
+ .filter(|record| {
+ record.source == "agent-ready-task-scheduler"
+ && record.parent_run_id.as_deref() == Some(ROOT_RUN_ID)
+ })
+ .collect::>();
+ assert_eq!(logical_code_runs.len(), 1);
+ assert_eq!(logical_code_runs[0].run_id, completed_code.run_id);
+ assert_eq!(logical_code_runs[0].phase, "completed");
+ let root_terminal_records = read_all_game_creator_agent_runtime_tasks(
+ &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
+ )
+ .expect("read deterministic root journal")
+ .into_iter()
+ .filter(|record| record.run_id == ROOT_RUN_ID && record.phase == "completed")
+ .count();
+ assert_eq!(
+ root_terminal_records, 1,
+ "root must have one completed terminal"
+ );
+ let (db_records, _) =
+ read_agent_db_records_bounded(&root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)
+ .expect("read deterministic Agent DB records");
+ assert_eq!(
+ db_records
+ .iter()
+ .filter(|record| {
+ record.get("recordType").and_then(serde_json::Value::as_str)
+ == Some("agent.runtime.completed")
+ && record.get("agentId").and_then(serde_json::Value::as_str)
+ == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
+ && record.get("runId").and_then(serde_json::Value::as_str) == Some(ROOT_RUN_ID)
+ })
+ .count(),
+ 1,
+ "root completion audit must be unique"
+ );
+ for agent_id in [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "code-prototype"] {
+ let records = read_all_game_creator_agent_runtime_tasks(
+ &game_creator_agent_runtime_task_path(&root, agent_id),
+ )
+ .expect("read no-intervention journal");
+ assert!(records.iter().all(|record| {
+ record.status != "waiting-for-confirmation"
+ && record.status != "waiting-for-user-input"
+ && record.phase != "waiting-for-confirmation"
+ && record.phase != "waiting-for-user-input"
+ }));
+ }
+ assert!(!game_creator_agent_runtime_pending_tool_action_path(
+ &root,
+ &completed_code.agent_id,
+ &completed_code.run_id,
+ )
+ .exists());
+ assert!(!game_creator_agent_runtime_pending_tool_action_path(
+ &root,
+ &completed_root.agent_id,
+ &completed_root.run_id,
+ )
+ .exists());
+ let confirmations_root = root.join(".agent/runtime/confirmations");
+ assert!(
+ !confirmations_root.exists()
+ || fs::read_dir(&confirmations_root)
+ .expect("read confirmation root")
+ .next()
+ .is_none(),
+ "deterministic game-chat completion must not require confirmation"
+ );
+}
+
#[test]
fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
assert_eq!(
@@ -3898,8 +4511,10 @@ fn game_chat_scheduler_recovers_deterministic_active_code_prototype_with_v1_task
const PARENT_RUN_ID: &str = "game-chat-v1-ready-text-recovery-parent";
const PARENT_TASK: &str = "继续把已有美术接入俄罗斯方块";
const MAIN_AGENT_ID: &str = "code-prototype";
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"game-chat-v1-task-recovery-key"}}"#.to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "game-chat-v1-task-recovery-user",
+ "game-chat-v1-task-recovery-key",
+ "https://dev.genarrative.world",
);
let temporary = tempfile::tempdir().expect("create v1 task recovery root");
let root = temporary.path().join("project");
@@ -4390,8 +5005,10 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() {
const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent";
const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物";
const CHILD_ID: &str = "art-director";
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(),
+ let _platform_session = crate::platform_session::install_test_platform_session(
+ "visual-recovery-user",
+ "visual-recovery-test-key",
+ "https://dev.genarrative.world",
);
let temporary = tempfile::tempdir().expect("create visual recovery root");
let root = temporary.path().join("project");
@@ -4629,7 +5246,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.expect("read autonomous Supervisor runtime")
.state;
- for _ in 0..1_500 {
+ for _ in 0..500 {
if runtime.status == "idle" || runtime.status == "failed" {
break;
}
@@ -4642,11 +5259,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
let fallback = format!(
"项目已完成生成,并通过当前 revision {revision} 的静态检查和桌面、移动端交互试玩验证。"
);
- assert_eq!(
- runtime.status, "idle",
- "phase={}, currentAction={}, waitingOn={}, error={:?}",
- runtime.phase, runtime.current_action, runtime.waiting_on, runtime.error
- );
+ assert_eq!(runtime.status, "idle");
assert_eq!(runtime.phase, "completed");
assert_eq!(runtime.last_response.as_deref(), Some(fallback.as_str()));
@@ -4766,311 +5379,3 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
.chunks_exact(2)
.all(|pair| pair == ["started", "failed"]));
}
-
-fn planning_submit_completion_fixture_at(
- root: &Path,
-) -> (
- AgentRuntimeState,
- AgentRuntimePendingToolAction,
- PlanSubmitGddResultV1,
- String,
-) {
- init_local_game_project_at(root, "planning-submit-completion", "策划提交终态恢复")
- .expect("initialize planning submit completion project");
- let parent_run_id = "planning-submit-completion-parent-run";
- start_game_creator_agent_runtime_task_at(
- root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- "收敛 Fast GDD",
- parent_run_id,
- AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
- "准备委派立项策划 Agent",
- vec!["委派 project-planning".to_string()],
- )
- .expect("start planning Supervisor root");
- bind_game_creator_agent_runtime_run_profile_at(
- root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- parent_run_id,
- AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
- Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
- None,
- )
- .expect("bind planning Supervisor root");
-
- let planning_lane = try_acquire_game_creator_agent_runtime_task_lock(
- root,
- GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
- )
- .expect("acquire planning child lane")
- .expect("planning child lane is available");
- let delegate_action_id = "action-111111111111111111111111";
- let observation = observe_agent_runtime_agent_delegate(
- root,
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- parent_run_id,
- Some(delegate_action_id),
- &serde_json::json!({
- "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
- "task": "输出可审批的 Fast GDD",
- "acceptanceCriteria": ["提交 strict Fast GDD"],
- "expectedArtifacts": [],
- "repairOfDelegationId": null,
- "runId": null
- }),
- );
- assert_eq!(observation.status, "ok", "{observation:?}");
- let delegation_id = agent_runtime_delegation_id(
- GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
- parent_run_id,
- GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
- delegate_action_id,
- );
- let child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id(
- root,
- GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
- &delegation_id,
- )
- .expect("read planning child task")
- .expect("planning child task exists");
- drop(planning_lane);
-
- let mut runtime = agent_runtime_state_from_task_record(&child_task);
- runtime.loop_iteration = 1;
- let action = AgentRuntimeToolAction {
- tool: PLAN_SUBMIT_GDD_TOOL.to_string(),
- reason: Some("提交已校验的 Fast GDD".to_string()),
- input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}),
- };
- let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
- let occurrence_nonce = 42;
- let action_id = agent_runtime_tool_action_id(
- &runtime.run_id,
- runtime.loop_iteration,
- 0,
- occurrence_nonce,
- &action_fingerprint,
- );
- let now = unix_timestamp();
- let pending = AgentRuntimePendingToolAction {
- schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(),
- fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(),
- agent_id: runtime.agent_id.clone(),
- task_id: runtime.task_id.clone(),
- session_id: runtime.session_id.clone(),
- run_id: runtime.run_id.clone(),
- source: runtime.source.clone(),
- run_profile: runtime.run_profile.clone(),
- run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
- planning_session_binding: None,
- provider_batch_plan_update: None,
- task: runtime.current_task.clone(),
- goal_id: runtime.goal_id.clone(),
- goal_revision: runtime.goal_revision,
- goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, &runtime)
- .expect("read planning child goal snapshot"),
- loop_iteration: runtime.loop_iteration,
- action_index: 0,
- occurrence_nonce,
- thinking_summary: "Fast GDD 已通过 strict 校验".to_string(),
- plan: vec!["提交 Fast GDD".to_string()],
- fallback_response: String::new(),
- observations: Vec::new(),
- project_revision_before: read_game_creator_agent_runtime_project_revision(root)
- .expect("read planning submit project revision"),
- verification_gate_before: read_game_creator_agent_runtime_verification_gate(
- root,
- &runtime.agent_id,
- &runtime.run_id,
- )
- .expect("read planning submit verification gate"),
- planned_repository_context_fingerprint: build_repository_startup_context_at(root)
- .expect("read planning submit repository context")
- .fingerprint,
- planned_steer_cursor: runtime.applied_steer_cursor,
- action,
- action_id: action_id.clone(),
- action_fingerprint,
- input_summary: None,
- execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(),
- status: AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(),
- observation: None,
- created_at: now,
- updated_at: now,
- };
- let result = PlanSubmitGddResultV1 {
- outcome: "submitted".to_string(),
- gdd_ref: PlanGddRef {
- gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(),
- version: 1,
- fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)),
- },
- pending_action_id: action_id,
- approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(),
- recovery_pending: false,
- };
- (runtime, pending, result, delegation_id)
-}
-
-fn read_planning_submit_completion_jsonl(path: &Path) -> Vec {
- fs::read_to_string(path)
- .ok()
- .into_iter()
- .flat_map(|content| {
- content
- .lines()
- .filter(|line| !line.trim().is_empty())
- .map(|line| {
- serde_json::from_str::(line)
- .expect("parse planning submit completion JSONL")
- })
- .collect::>()
- })
- .collect()
-}
-
-fn planning_submit_completion_audit_count(
- root: &Path,
- record_type: &str,
- action_id: &str,
-) -> usize {
- read_planning_submit_completion_jsonl(&root.join(".agent/agent.db"))
- .into_iter()
- .filter(|record| {
- record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type)
- && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id)
- })
- .count()
-}
-
-#[test]
-fn planning_submit_child_completion_is_action_scoped_and_idempotent() {
- let temporary = tempfile::tempdir().expect("create planning submit completion root");
- let root = temporary.path().join("project");
- let (mut runtime, pending, result, delegation_id) =
- planning_submit_completion_fixture_at(&root);
-
- ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
- .expect("complete planning child first time");
- ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
- .expect("replay planning child completion");
-
- let task_projection_count = read_planning_submit_completion_jsonl(
- &game_creator_agent_runtime_task_path(&root, &runtime.agent_id),
- )
- .into_iter()
- .filter(|record| {
- record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str())
- && record.get("actionId").and_then(serde_json::Value::as_str)
- == Some(pending.action_id.as_str())
- && record.get("phase").and_then(serde_json::Value::as_str) == Some("completed")
- })
- .count();
- assert_eq!(task_projection_count, 1);
-
- let action_event_count = read_planning_submit_completion_jsonl(
- &game_creator_agent_runtime_event_path(&root, &runtime.agent_id),
- )
- .into_iter()
- .filter(|record| {
- record.get("eventType").and_then(serde_json::Value::as_str)
- == Some("plan.submit_gdd.committed")
- && record.get("actionId").and_then(serde_json::Value::as_str)
- == Some(pending.action_id.as_str())
- })
- .count();
- assert_eq!(action_event_count, 1);
-
- let matching_deliveries = list_static_delegate_deliveries_at(&root)
- .expect("list planning child deliveries")
- .into_iter()
- .filter(|delivery| delivery.delegation_id == delegation_id)
- .collect::>();
- assert_eq!(matching_deliveries.len(), 1);
- assert_eq!(
- matching_deliveries[0].status,
- StaticDelegateDeliveryStatus::Ready
- );
- assert_eq!(
- matching_deliveries[0].terminal_status.as_deref(),
- Some("completed")
- );
- assert_eq!(
- planning_submit_completion_audit_count(
- &root,
- "agent.runtime.agent.delegate_receipt.ready",
- "action-111111111111111111111111",
- ),
- 1
- );
- assert_eq!(
- planning_submit_completion_audit_count(
- &root,
- "agent.runtime.plan_submit_gdd.committed",
- &pending.action_id,
- ),
- 1
- );
-}
-
-#[test]
-fn planning_submit_child_completion_waits_for_exact_delivery_before_committed_audit() {
- let temporary = tempfile::tempdir().expect("create planning submit delivery recovery root");
- let root = temporary.path().join("project");
- let (mut runtime, pending, result, delegation_id) =
- planning_submit_completion_fixture_at(&root);
- let delivery = read_static_delegate_delivery_at(&root, &delegation_id)
- .expect("read dispatched planning delivery")
- .expect("dispatched planning delivery exists");
- fs::remove_file(
- root.join(".agent/runtime/delegation-deliveries")
- .join(format!("{delegation_id}.json")),
- )
- .expect("remove planning delivery to model post-commit interruption");
-
- let error =
- ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
- .expect_err("completion must wait for exact durable delivery");
- assert!(error.contains("durable delivery 不存在"), "{error}");
- assert_eq!(
- planning_submit_completion_audit_count(
- &root,
- "agent.runtime.plan_submit_gdd.committed",
- &pending.action_id,
- ),
- 0,
- "delivery 未 durable 前不得写 recoveryPending=false committed audit"
- );
-
- create_or_read_static_delegate_delivery_at(&root, &delivery)
- .expect("restore exact dispatched planning delivery");
- ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
- .expect("resume same planning submit action after delivery repair");
- let recovered_delivery = read_static_delegate_delivery_at(&root, &delegation_id)
- .expect("read recovered planning delivery")
- .expect("recovered planning delivery exists");
- assert_eq!(
- recovered_delivery.status,
- StaticDelegateDeliveryStatus::Ready
- );
- assert_eq!(
- recovered_delivery.terminal_status.as_deref(),
- Some("completed")
- );
- let committed = read_planning_submit_completion_jsonl(&root.join(".agent/agent.db"))
- .into_iter()
- .filter(|record| {
- record.get("recordType").and_then(serde_json::Value::as_str)
- == Some("agent.runtime.plan_submit_gdd.committed")
- && record.get("actionId").and_then(serde_json::Value::as_str)
- == Some(pending.action_id.as_str())
- })
- .collect::>();
- assert_eq!(committed.len(), 1);
- assert_eq!(
- committed[0]
- .get("recoveryPending")
- .and_then(serde_json::Value::as_bool),
- Some(false)
- );
-}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs
index 9180657d5..865ef68d7 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs
@@ -346,6 +346,24 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at_locked(
if !project_lock.guards_project_root(root)? {
return Err("Supervisor 澄清 pending 投影缺少当前项目写锁".to_string());
}
+ let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id(
+ root,
+ &runtime.agent_id,
+ &runtime.run_id,
+ )?
+ .ok_or_else(|| "needs-user-input 转换缺少当前 parent task".to_string())?;
+ if parent_task.session_id != runtime.session_id
+ || parent_task.source != runtime.source
+ || parent_task.run_profile != runtime.run_profile
+ || parent_task.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint
+ {
+ return Err("needs-user-input 转换的 parent task 与 runtime 身份不一致".to_string());
+ }
+ if convert_claimed_game_chat_user_input_deliveries_to_repair_at(root, &parent_task, deliveries)?
+ > 0
+ {
+ return Ok(false);
+ }
let mut pending_deliveries = deliveries.iter().filter(|delivery| {
delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsUserInput
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
index 9fb62c210..91ff9e77f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
@@ -1610,14 +1610,8 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask
} else {
""
};
- let verification_requirement = match task.id.as_str() {
- "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
- task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
- "publish-package" => "publish-package 不在前置固定 owner 的内部产物验证范围内;必须继续按现有发布完成合同和当前 run 的可用验证门收束,不得借用前置 owner 的验证凭证。",
- _ => "完成修改后按当前任务的既有验证合同收束。",
- };
format!(
- "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。{verification_requirement}不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
+ "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
)
}
@@ -1647,14 +1641,9 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
"{base}\n\n这是 autonomous-game-build 的只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
);
}
- if task.id == "art-director" {
- if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
- return format!(
- "{base}\n\n这是 autonomous-game-build 的条件 Canvas owner 任务。当前已配置 External Editor 生图凭据,必须按角色固定合同生成并登记 assets/art-spec.png。canvas.asset_generate 成功只表示候选已生成并登记,不等于视觉门已经通过;完成后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。项目 mutation 仅限固定 Canvas 产物;不得调用 game.static_smoke、project.verify、command.run_limited、preview 或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
- );
- }
+ if task.id == "art-director" && editor_api_key_is_configured() {
return format!(
- "{base}\n\n这是 autonomous-game-build 的无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。Runtime 会在子 Run 终态后幂等投影 manifest。"
+ "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
);
}
if autonomous_manifest_task_requires_project_mutation(&task.id) {
@@ -1701,89 +1690,3 @@ pub(in crate::agent) fn render_manifest_ready_task_background_prompt(
acceptance
)
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn seed_task(task_id: &str) -> GameCreationAppTaskState {
- new_game_creation_app_seed_tasks()
- .into_iter()
- .find(|task| task.id == task_id)
- .unwrap_or_else(|| panic!("missing seed task {task_id}"))
- }
-
- #[test]
- fn autonomous_ready_task_prompts_separate_owner_artifacts_from_playable_validation() {
- let _config_guard = crate::tests::write_test_local_config("{}".to_string());
-
- for task_id in [
- "design-foundation",
- "balance-seed",
- "art-asset-plan",
- "audio-asset-plan",
- ] {
- let prompt =
- render_autonomous_manifest_ready_task_background_prompt(&seed_task(task_id));
- assert!(prompt.contains("由 Runtime 在收束门内验证本人固定 owner 产物"));
- assert!(
- prompt.contains("禁止调用 game.static_smoke、project.verify、command.run_limited")
- );
- }
-
- let code_prompt =
- render_autonomous_manifest_ready_task_background_prompt(&seed_task("code-prototype"));
- assert!(code_prompt.contains("必须对可玩入口执行 game.static_smoke"));
- assert!(!code_prompt.contains("验证本人固定 owner 产物"));
-
- let readiness_prompt = render_autonomous_manifest_ready_task_background_prompt(&seed_task(
- "preview-readiness",
- ));
- assert!(
- readiness_prompt.contains("且只能是 command.run_limited(commandId=game.static_smoke)")
- );
- assert!(!readiness_prompt.contains("preview.validate"));
-
- let publish_prompt =
- render_autonomous_manifest_ready_task_background_prompt(&seed_task("publish-package"));
- assert!(publish_prompt.contains("不在前置固定 owner 的内部产物验证范围内"));
- assert!(!publish_prompt.contains("验证本人固定 owner 产物"));
- }
-
- #[test]
- fn art_director_ready_task_is_read_only_without_key_and_canvas_owner_with_key() {
- let task = seed_task("art-director");
- {
- let _config_guard = crate::tests::write_test_local_config("{}".to_string());
- let prompt = render_autonomous_manifest_ready_task_background_prompt(&task);
- assert!(prompt.contains("无生图凭据只读协调任务"));
- assert!(prompt.contains("assets/art-spec.png 图片产物与生成验收条款在本轮不适用"));
- assert!(prompt.contains("不要修改项目文件"));
- assert!(!autonomous_manifest_ready_task_requires_visual_asset(
- "art-director"
- ));
- assert!(agent_runtime_task_requires_read_only_delivery(
- "art-director",
- &prompt
- ));
- }
- {
- let _config_guard = crate::tests::write_test_local_config(
- r#"{"editorApi":{"apiKey":"art-director-ready-task-key"}}"#.to_string(),
- );
- let prompt = render_autonomous_manifest_ready_task_background_prompt(&task);
- assert!(prompt.contains("条件 Canvas owner 任务"));
- assert!(prompt.contains("生成并登记 assets/art-spec.png"));
- assert!(prompt.contains("成功只表示候选已生成并登记,不等于视觉门已经通过"));
- assert!(prompt.contains("由 Runtime 在收束时核对当前 revision"));
- assert!(!prompt.contains("成功会为本人当前 revision 形成验证凭证"));
- assert!(autonomous_manifest_ready_task_requires_visual_asset(
- "art-director"
- ));
- assert!(!agent_runtime_task_requires_read_only_delivery(
- "art-director",
- &prompt
- ));
- }
- }
-}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
index 9249a7998..580fcf120 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
@@ -688,8 +688,13 @@ fn rendered_html_markup_and_stylesheets(content: &str) -> (String, String) {
.unwrap_or(lower.len());
continue;
}
+ if !html_tag_like_start(&lower, tag_start) {
+ cursor = tag_start + 1;
+ continue;
+ }
let Some(tag_after) = html_tag_end(&lower, tag_start) else {
- break;
+ cursor = tag_start + 1;
+ continue;
};
let tag_end = tag_after - 1;
let tag = &lower[tag_start..=tag_end];
@@ -6319,7 +6324,21 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
));
}
};
- if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) {
+ let static_smoke_matches_current_entry =
+ match agent_runtime_static_smoke_passed_for_current_entry_at(
+ root,
+ &gate,
+ revision.revision,
+ ) {
+ Ok(matches) => matches,
+ Err(error) => {
+ return Some(autonomous_completion_blocker(
+ "preview-readiness 无法复核当前 game.static_smoke 入口摘要",
+ error,
+ ));
+ }
+ };
+ if !static_smoke_matches_current_entry {
return Some(autonomous_completion_blocker(
"preview-readiness 尚未通过当前 revision 的 game.static_smoke",
format!(
@@ -6372,9 +6391,29 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
));
}
};
- if receipt.revision != revision.revision {
+ let current_index = match read_autonomous_evidence_file_at(
+ root,
+ AGENT_RUNTIME_GAME_INDEX_PATH,
+ "preview-playtest 当前游戏入口",
+ AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES,
+ ) {
+ Ok(Some((digest, _))) => digest,
+ Ok(None) => {
+ return Some(autonomous_completion_blocker(
+ "preview-playtest 当前游戏入口缺失",
+ "必须重新生成 game/index.html 并在当前 revision 执行 preview.validate。",
+ ));
+ }
+ Err(error) => {
+ return Some(autonomous_completion_blocker(
+ "preview-playtest 当前游戏入口无法安全读取",
+ error,
+ ));
+ }
+ };
+ if receipt.revision != revision.revision || receipt.game_index != current_index {
return Some(autonomous_completion_blocker(
- "preview-playtest 浏览器试玩回执不属于当前 revision",
+ "preview-playtest 浏览器试玩回执不属于当前 revision 或入口产物",
format!(
"receiptRevision={}, currentRevision={}",
receipt.revision, revision.revision
@@ -6589,13 +6628,13 @@ fn game_chat_main_completion_blocker_at_locked(
),
));
}
- let current_index_bytes = match read_autonomous_evidence_file_at(
+ let (current_index, current_index_bytes) = match read_autonomous_evidence_file_at(
root,
AGENT_RUNTIME_GAME_INDEX_PATH,
"game-chat 主 Agent 游戏入口",
AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES,
) {
- Ok(Some((_, bytes))) => bytes,
+ Ok(Some(value)) => value,
Ok(None) => {
return Some(autonomous_completion_blocker(
"game-chat code-prototype 尚未生成 game/index.html",
@@ -6637,11 +6676,12 @@ fn game_chat_main_completion_blocker_at_locked(
));
}
};
- if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) {
+ if !agent_runtime_static_smoke_passed_for_entry(&gate, revision.revision, ¤t_index.sha256)
+ {
return Some(autonomous_completion_blocker(
- "game-chat code-prototype 尚未通过当前 revision 的 game.static_smoke",
+ "game-chat code-prototype 尚未通过当前 game/index.html 的 game.static_smoke",
format!(
- "currentRevision={}, staticSmokeVerifiedRevision={}",
+ "path=game/index.html, currentRevision={}, staticSmokeVerifiedRevision={}",
revision.revision,
gate.static_smoke_verified_revision
.map(|value| value.to_string())
@@ -6664,9 +6704,9 @@ fn game_chat_main_completion_blocker_at_locked(
));
}
};
- if receipt.revision != revision.revision {
+ if receipt.revision != revision.revision || receipt.game_index != current_index {
return Some(autonomous_completion_blocker(
- "game-chat code-prototype 试玩回执不属于当前 revision",
+ "game-chat code-prototype 试玩回执不属于当前 revision 或入口产物",
format!(
"receiptRevision={}, currentRevision={}",
receipt.revision, revision.revision
@@ -7528,6 +7568,59 @@ pub(in crate::agent) fn javascript_is_syntactically_valid(content: &str, is_modu
semantic.diagnostics.is_empty()
}
+pub(in crate::agent) fn validate_executable_inline_javascript_syntax(
+ html: &str,
+) -> Result<(), String> {
+ let lower = html.to_ascii_lowercase();
+ let mut cursor = 0usize;
+ let mut executable_index = 0usize;
+ while let Some(offset) = lower[cursor..].find('<') {
+ let tag_start = cursor + offset;
+ if lower[tag_start..].starts_with("")
+ .map(|end| tag_start + 4 + end + 3)
+ .unwrap_or(lower.len());
+ continue;
+ }
+ let Some(tag_after) = html_tag_end(&lower, tag_start) else {
+ break;
+ };
+ let tag = &lower[tag_start..tag_after];
+ if let Some(end) = html_non_executable_container_end(&lower, tag, tag_after - 1) {
+ cursor = end;
+ continue;
+ }
+ if !html_tag_starts_element(tag, "script") {
+ cursor = tag_after;
+ continue;
+ }
+ let Some((close_start, close_end)) =
+ raw_text_html_element_close(&lower, tag_after, "script")
+ else {
+ return Err("游戏入口的
+
+