From 9bb048c11b9dd153cc196ba64b455ccfd43973cc Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Tue, 21 Jul 2026 12:53:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=87=AA=E4=B8=BB=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E8=BF=90=E8=A1=8C=E6=97=B6=E5=B9=B6=E6=8F=90=E9=AB=98?= =?UTF-8?q?=20Runner=20=E5=90=AF=E5=8A=A8=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善 Agent Swarm 自主游戏构建、委派协作、验证门和试玩闭环 补齐浏览器验证、CLI 入口、项目状态与运行时测试覆盖 将外部 Agent Runner 冷启动超时提高到 30 秒并增加回归测试 同步真实 E2E 自测脚本、配置检查和项目决策记录 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-runtime-real-e2e.mjs | 2255 ++++++++++- .../scripts/check-config.mjs | 8 +- .../src-tauri/src/agent.rs | 3491 ++++++++++++++++- .../src-tauri/src/agent_native_tools.rs | 3 +- .../src-tauri/src/browser.rs | 1889 ++++++++- .../src-tauri/src/cli.rs | 61 +- .../src-tauri/src/collaboration.rs | 94 +- .../src-tauri/src/commands.rs | 27 + .../src-tauri/src/main.rs | 23 + .../src-tauri/src/mcp.rs | 2 + .../src-tauri/src/project.rs | 88 +- .../src-tauri/src/runner.rs | 7 +- .../src-tauri/src/swarm_cli.rs | 1579 +++++++- .../src-tauri/src/tests.rs | 2669 ++++++++++++- .../src-tauri/src/windows.rs | 19 +- apps/ai-game-creator-shell/src/App.tsx | 14 +- .../tests/appSurface.test.ts | 42 +- .../shared-memory/decision-log.md | 6 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- package.json | 1 + 21 files changed, 12041 insertions(+), 244 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 491b127a7..3f41976b7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -18,6 +18,7 @@ "agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery", "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-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", "agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry", "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 9de30a314..5d5bd4667 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -92,6 +92,10 @@ const supervisorSwarmAutonomousChatAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.json'; const supervisorSwarmAutonomousChatAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.v1'; +const supervisorAutonomousPlayableAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-autonomous-playable-appdata.json'; +const supervisorAutonomousPlayableAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-autonomous-playable-appdata.v1'; const supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-static-isolated-autonomous-chat-appdata.json'; const supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelSchema = @@ -141,6 +145,8 @@ const supervisorSwarmTransientRetryTargetAgentId = projectSupervisorAgentId; const supervisorSwarmTransientRetryBackoffMs = 30_000; const supervisorSwarmTransientRetryPreDueGuardMs = 1_500; const supervisorSwarmAutonomousChatSuite = 'supervisor-swarm-autonomous-chat'; +const supervisorAutonomousPlayableLaneDefenseSuite = + 'supervisor-autonomous-playable-lane-defense'; const supervisorSwarmStaticIsolatedAutonomousChatSuite = 'supervisor-swarm-static-isolated-autonomous-chat'; const supervisorSwarmCollaborationPolicyMixedRecoverySuite = @@ -151,6 +157,12 @@ const providerRequestLifecycleSchemaVersion = 'game-creator-provider-request-lifecycle.v2'; const providerActionBatchSchemaVersion = 'game-creator-provider-action-batch.v2'; +const runProfileBindingSchemaVersion = 'game-creator-run-profile-binding.v1'; +const autonomousCompletionContractSchemaVersion = + 'game-creator-autonomous-completion-contract.v1'; +const autonomousPlaytestReceiptSchemaVersion = + 'game-creator-autonomous-playtest-receipt.v1'; +const autonomousGameBuildRunProfile = 'autonomous-game-build'; const supervisorCollaborationPolicySchemaVersion = 'game-creator-supervisor-collaboration-policy.v1'; const supervisorCollaborationContractSchemaVersion = @@ -327,6 +339,99 @@ const supervisorSwarmConfirmedTools = [ ]; const supervisorSwarmAutonomousTask = '请把这个试玩项目推进到可以交给首批玩家体验的状态。以仓库现有的正式交付要求、临时检查要求和实际验证结果为准;按阶段生效的临时检查必须在各自生效后全部完成,不能把后续检查并入前置检查或漏掉。完成后简短说明交付内容、验证结论和仍需关注的问题。'; +const supervisorAutonomousPlayableLaneDefenseTask = + '做一个植物大战僵尸式的塔防游戏,要能选择植物、阻挡敌人、正常闯关,并且生成后可以直接试玩。'; +const supervisorAutonomousPlayableSourceFieldMaxChars = 8_000; +const supervisorAutonomousPlayableSourceTotalMaxChars = 10_000; +const supervisorAutonomousPlayableSafeStatuses = new Set([ + 'cancelling', + 'budget-exhausted', + 'cancelled', + 'completed', + 'failed', + 'idle', + 'needs-reconciliation', + 'paused', + 'pausing', + 'pending', + 'running', + 'waiting-for-confirmation', + 'waiting-for-user-input', +]); +const supervisorAutonomousPlayableSafePhases = new Set([ + 'action', + 'budget-exhausted', + 'cancelled', + 'cancelling', + 'completion-contract-failed', + 'completed', + 'conversation-write-failed', + 'executing', + 'failed', + 'finalizing', + 'idle', + 'needs-reconciliation', + 'observation', + 'parent-terminal', + 'paused', + 'pausing', + 'planning', + 'provider-action-batch', + 'queued', + 'response', + 'running', + 'waiting-for-agent', + 'waiting-for-confirmation', + 'waiting-for-delegate-receipts', + 'waiting-for-isolated-join', + 'waiting-for-process-session', + 'waiting-for-provider-retry', + 'waiting-for-user-input', +]); +const supervisorAutonomousPlayableSafeDeliveryStatuses = new Set([ + 'claimed-by-parent', + 'dispatched', + 'ready', + 'suppressed', +]); +const supervisorAutonomousPlayableSafeTerminalStatuses = new Set([ + 'budget-exhausted', + 'cancelled', + 'completed', + 'failed', +]); +const supervisorAutonomousPlayableSafeTurnOutcomes = new Set([ + 'failed', + 'incomplete', + 'needs-reconciliation', + 'settled', +]); +const laneDefensePlaytestRequiredAssertions = [ + 'state-surface-valid', + 'level-positive', + 'start-control-clicked', + 'start-sequence-advanced', + 'start-phase-playing', + 'defender-option-control-clicked', + 'defender-selection-sequence-advanced', + 'defender-selection-recorded', + 'lane-cell-control-clicked', + 'defender-placement-sequence-advanced', + 'defender-count-increased', + 'enemies-present-after-placement', + 'speed-up-control-clicked', + 'battle-sequence-advanced', + 'battle-sequence-monotonic', + 'enemy-position-changed', + 'enemy-health-decreased', + 'phase-won', + 'next-level-control-clicked', + 'next-level-sequence-advanced', + 'level-increased', + 'restart-control-clicked', + 'restart-sequence-advanced', + 'restart-phase-ready-or-playing', +]; const supervisorSwarmAutonomousRoutingTerms = [ supervisorSwarmDesignAgentId, supervisorSwarmQualityAgentId, @@ -442,6 +547,7 @@ const processEchoPrefix = 'GENARRATIVE_PROCESS_ECHO'; const processStoppedMarker = 'GENARRATIVE_PROCESS_STOPPED'; const pollIntervalMs = 750; const runTimeoutMs = 30 * 60 * 1000; +const supervisorAutonomousPlayableRunTimeoutMs = 60 * 60 * 1000; const supervisorSwarmTerminalSidecarCleanupTimeoutMs = 10_000; const processRunnerKillStartTimeoutMs = 5 * 60 * 1000; const commandOutputLimit = 4 * 1024 * 1024; @@ -544,6 +650,7 @@ let linuxPidfdPythonPath = null; let cleanupInProgress = false; let userInputCliSession = null; let supervisorSwarmCliSession = null; +let supervisorAutonomousPlayableCliSession = null; class StreamingSecretScanner { constructor(secrets) { @@ -772,6 +879,16 @@ const state = { privateValues: [], reportLeakCount: 0, }, + supervisorAutonomousPlayable: { + initialGameIndexSha256: null, + stdinWriteCount: 0, + stdinEnded: false, + stdinBytes: 0, + turnReport: null, + cliOutput: '', + privateValues: [], + reportLeakCount: 0, + }, supervisorSwarm: { effectiveModel: null, effectiveApiKind: null, @@ -943,7 +1060,7 @@ const selfTestRequested = process.argv.length === 3 && process.argv[2] === '--self-test'; if (selfTestRequested) { - const selfTestEvidence = runAgentRuntimeRealE2eSelfTests(); + const selfTestEvidence = await runAgentRuntimeRealE2eSelfTests(); process.stdout.write(`${JSON.stringify(selfTestEvidence, null, 2)}\n`); } else { try { @@ -962,6 +1079,9 @@ try { if (isScopedAgentsSuite()) state.evidence = emptyScopedAgentsEvidence(); if (isProjectSkillSuite()) state.evidence = emptyProjectSkillEvidence(); if (isParallelReadSuite()) state.evidence = emptyParallelReadEvidence(); + if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + state.evidence = emptySupervisorAutonomousPlayableEvidence(); + } if (isSupervisorSwarmSuite()) { state.evidence = emptySupervisorSwarmEvidence(); } @@ -977,6 +1097,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ) { @@ -988,8 +1109,9 @@ try { state.transcriptScanner = new StreamingSecretScanner(state.secrets); state.config = await checkPrerequisites(loaded.config); - const required = - isProcessSessionSuite() || isIsolatedRunnerSuite() + const required = isSupervisorAutonomousPlayableLaneDefenseSuite() + ? ['llmConfigured', 'chromeAvailable'] + : isProcessSessionSuite() || isIsolatedRunnerSuite() ? ['llmConfigured'] : ['llmConfigured', 'chromeAvailable']; if (state.suite === 'full') { @@ -1021,6 +1143,8 @@ try { await runProjectSkillE2e(); } else if (isParallelReadSuite()) { await runParallelReadE2e(); + } else if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + await runSupervisorAutonomousPlayableLaneDefenseE2e(); } else if (isSupervisorSwarmSuite()) { await runSupervisorSwarmE2e(); } else if (isProcessSessionSuite()) { @@ -1050,6 +1174,18 @@ try { } userInputCliSession = null; } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + supervisorAutonomousPlayableCliSession + ) { + try { + await closeInteractiveCli(supervisorAutonomousPlayableCliSession); + } catch (error) { + state.status = 'FAIL'; + recordError('supervisor-autonomous-playable-cli-cleanup-failed', error); + } + supervisorAutonomousPlayableCliSession = null; + } if (isSupervisorSwarmInteractiveChatSuite() && supervisorSwarmCliSession) { try { if ( @@ -1290,6 +1426,28 @@ try { state.status = 'FAIL'; recordError('parallel-read-formal-config-cli-call-detected'); } + } else if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + state.evidence.supervisorAutonomousPlayableRunnerStopped = + state.isolatedRunner.stopped; + state.evidence.supervisorAutonomousPlayableAppDataCleanupPerformed = + state.isolatedRunner.cleanupPerformed; + state.evidence.formalConfigCliCallCount = + state.isolatedRunner.sourceConfigCliCallCount; + state.evidence.sourceRunnerEndpointUnchanged = + state.isolatedRunner.sourceRunnerEndpointUnchanged; + state.evidence.sourceAppDataDirectoryUntouched = + state.isolatedRunner.sourceAppDataDirectoryUntouched; + state.evidence.sourceConfigReplicaCount = + state.isolatedRunner.configLinks.length; + state.evidence.sourceConfigReplicasVerified = + state.isolatedRunner.sourceConfigLinksVerified; + state.evidence.isolatedAppDataUsed = true; + if (state.isolatedRunner.sourceConfigCliCallCount > 0) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-formal-config-cli-call-detected', + ); + } } else if (isSupervisorSwarmSuite()) { state.evidence.supervisorSwarmRunnerStopped = state.isolatedRunner.stopped; @@ -1514,6 +1672,24 @@ try { recordError('parallel-read-partial-evidence-read-failed', error); } } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + state.projectRoot && + state.status !== 'PASS' && + state.evidence.evidenceCompleteness !== 'complete' + ) { + try { + state.evidence = { + ...state.evidence, + ...(await collectPartialSupervisorAutonomousPlayableEvidence()), + }; + } catch (error) { + recordError( + 'supervisor-autonomous-playable-partial-evidence-read-failed', + error, + ); + } + } if ( isSupervisorSwarmSuite() && state.projectRoot && @@ -1750,6 +1926,20 @@ try { report = JSON.stringify(summary, null, 2); } } + if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + state.supervisorAutonomousPlayable.reportLeakCount = countExactSecrets( + Buffer.from(report), + state.supervisorAutonomousPlayable.privateValues, + ); + state.evidence.supervisorAutonomousPlayableReportLeakCount = + state.supervisorAutonomousPlayable.reportLeakCount; + if (state.supervisorAutonomousPlayable.reportLeakCount > 0) { + state.status = 'FAIL'; + recordError('supervisor-autonomous-playable-report-body-leak-detected'); + summary = buildSummary(); + report = JSON.stringify(summary, null, 2); + } + } if (isSupervisorSwarmSuite()) { state.supervisorSwarm.reportLeakCount = countExactSecrets( Buffer.from(report), @@ -1784,6 +1974,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ) { @@ -1848,6 +2039,13 @@ try { const remainingParallelReadReportLeakCount = isParallelReadSuite() ? countExactSecrets(Buffer.from(report), state.parallelRead.privateValues) : 0; + const remainingSupervisorAutonomousPlayableReportLeakCount = + isSupervisorAutonomousPlayableLaneDefenseSuite() + ? countExactSecrets( + Buffer.from(report), + state.supervisorAutonomousPlayable.privateValues, + ) + : 0; const remainingSupervisorSwarmReportLeakCount = isSupervisorSwarmSuite() ? countExactSecrets( Buffer.from(report), @@ -1862,6 +2060,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ? countExactSecrets(Buffer.from(report), formalConfigPathVariants()) @@ -1875,6 +2074,7 @@ try { remainingScopedAgentsReportLeakCount > 0 || remainingProjectSkillReportLeakCount > 0 || remainingParallelReadReportLeakCount > 0 || + remainingSupervisorAutonomousPlayableReportLeakCount > 0 || remainingSupervisorSwarmReportLeakCount > 0 || remainingFormalConfigPathReportLeakCount > 0 ) { @@ -1894,6 +2094,8 @@ try { ? 'project-skill-report-redaction-required' : remainingParallelReadReportLeakCount > 0 ? 'parallel-read-report-redaction-required' + : remainingSupervisorAutonomousPlayableReportLeakCount > 0 + ? 'supervisor-autonomous-playable-report-redaction-required' : remainingSupervisorSwarmReportLeakCount > 0 ? 'supervisor-swarm-report-redaction-required' : remainingFormalConfigPathReportLeakCount > 0 @@ -1917,6 +2119,8 @@ try { scopedAgentsReportLeakCount: remainingScopedAgentsReportLeakCount, projectSkillReportLeakCount: remainingProjectSkillReportLeakCount, parallelReadReportLeakCount: remainingParallelReadReportLeakCount, + supervisorAutonomousPlayableReportLeakCount: + remainingSupervisorAutonomousPlayableReportLeakCount, supervisorSwarmReportLeakCount: remainingSupervisorSwarmReportLeakCount, formalConfigPathReportLeakCount: @@ -12138,6 +12342,1506 @@ async function captureSupervisorSwarmTransientRetryCheckpoint() { throw codedError('supervisor-swarm-transient-retry-held-attempt-timeout'); } +function buildSupervisorAutonomousPlayableStdin() { + const task = supervisorAutonomousPlayableLaneDefenseTask; + assert( + task === + '做一个植物大战僵尸式的塔防游戏,要能选择植物、阻挡敌人、正常闯关,并且生成后可以直接试玩。' && + !/[\r\n]/u.test(task), + 'supervisor-autonomous-playable-task-not-exact', + ); + for (const forbidden of [ + projectSupervisorAgentId, + autonomousGameBuildRunProfile, + 'Agent', + 'Runtime', + 'approve', + 'answer', + 'steer', + 'runner', + 'tool', + 'actionId', + 'runId', + ]) { + assert( + !task.includes(forbidden), + 'supervisor-autonomous-playable-task-recipe-leak', + ); + } + return Buffer.from(`${task}\n`, 'utf8'); +} + +function parseSingleSwarmTurnReport(output, codePrefix) { + const reportLines = output + .split(/\r?\n/u) + .filter((line) => line.startsWith('[turn.report] ')); + assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`); + const report = JSON.parse(reportLines[0].slice('[turn.report] '.length)); + assert( + isPlainObject(report) && + JSON.stringify(Object.keys(report).sort()) === + JSON.stringify( + [ + 'schemaVersion', + 'outcome', + 'parentAgentId', + 'sessionId', + 'parentRunId', + 'runtimeCount', + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'newAssistantMessageCount', + 'finalReplyChars', + 'reconciliationAgentCount', + ].sort(), + ), + `${codePrefix}-turn-report-shape-invalid`, + ); + return report; +} + +async function waitForSupervisorAutonomousPlayableParentRuntime(task) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const runtime = await readRuntime(projectSupervisorAgentId).catch( + () => null, + ); + if ( + runtime?.agentId === projectSupervisorAgentId && + runtime.sessionId === supervisorSwarmSessionId && + isNonEmptyString(runtime.runId) && + runtime.currentTask === task && + runtime.runProfile === autonomousGameBuildRunProfile && + /^[0-9a-f]{64}$/u.test(runtime.runProfileBindingFingerprint ?? '') + ) { + return runtime; + } + const session = supervisorAutonomousPlayableCliSession; + if (session?.closed && session.closeInfo?.code !== 0) { + throw codedProcessError('supervisor-autonomous-playable-cli-failed', { + ...session.closeInfo, + stdout: session.stdout, + stderr: session.stderr, + }); + } + await sleep(50); + } + throw codedError('supervisor-autonomous-playable-parent-runtime-timeout'); +} + +async function waitForSupervisorAutonomousPlayableCliExit(session) { + let timeoutHandle; + const timeout = new Promise((resolve) => { + timeoutHandle = setTimeout( + () => resolve(null), + supervisorAutonomousPlayableRunTimeoutMs, + ); + }); + let result; + try { + result = await Promise.race([session.closePromise, 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) { + throw codedProcessError('supervisor-autonomous-playable-cli-failed', { + ...result, + stdout: session.stdout, + stderr: session.stderr, + }); + } + const report = parseSingleSwarmTurnReport( + output, + 'supervisor-autonomous-playable', + ); + state.supervisorAutonomousPlayable.turnReport = report; + return report; +} + +async function readSupervisorAutonomousPlayableResidualSidecarCounts() { + const roots = { + confirmations: '.agent/runtime/confirmations', + finalizations: '.agent/runtime/finalizations', + parallelReadBatches: '.agent/runtime/parallel-read-batches', + pendingActions: '.agent/runtime/pending-actions', + providerActionBatches: '.agent/runtime/provider-action-batches', + providerHandoffs: '.agent/runtime/provider-handoffs', + providerRetries: '.agent/runtime/provider-retries', + toolPlanHandoffs: '.agent/runtime/tool-plan-handoffs', + userInput: '.agent/runtime/user-input', + }; + const counts = {}; + for (const [name, relativePath] of Object.entries(roots)) { + counts[name] = ( + await listFiles(path.join(state.projectRoot, relativePath)) + ).length; + } + return counts; +} + +function supervisorAutonomousPlayableSafeLifecycleLabel(value, allowed) { + if (value == null || value === '') return 'absent'; + return typeof value === 'string' && allowed.has(value) ? value : 'unknown'; +} + +function countSupervisorAutonomousPlayableLifecycleLabels( + records, + field, + allowed, +) { + const counts = {}; + for (const record of records) { + const label = supervisorAutonomousPlayableSafeLifecycleLabel( + record?.[field], + allowed, + ); + counts[label] = (counts[label] ?? 0) + 1; + } + return counts; +} + +function supervisorAutonomousPlayableSafeTurnCount(report, field) { + const value = report?.[field]; + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function summarizeSupervisorAutonomousPlayableSourcePayloadAudits(records) { + const protocols = records.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.protocol', + ); + let sourceMutationActionMax = 0; + let sourcePayloadMaxFieldChars = 0; + let sourcePayloadMaxTotalChars = 0; + let sourcePayloadPolicyViolationCount = 0; + for (const record of protocols) { + const mutationCount = record.autonomousSourceMutationActionCount; + const maxFieldChars = record.autonomousSourceMaxFieldChars; + const totalChars = record.autonomousSourceTotalChars; + const valid = + record.autonomousSourcePayloadValidated === true && + Number.isSafeInteger(mutationCount) && + mutationCount >= 0 && + mutationCount <= 1 && + Number.isSafeInteger(maxFieldChars) && + maxFieldChars >= 0 && + maxFieldChars <= supervisorAutonomousPlayableSourceFieldMaxChars && + Number.isSafeInteger(totalChars) && + totalChars >= maxFieldChars && + totalChars <= supervisorAutonomousPlayableSourceTotalMaxChars; + if (!valid) sourcePayloadPolicyViolationCount += 1; + if (Number.isSafeInteger(mutationCount)) { + sourceMutationActionMax = Math.max( + sourceMutationActionMax, + mutationCount, + ); + } + if (Number.isSafeInteger(maxFieldChars)) { + sourcePayloadMaxFieldChars = Math.max( + sourcePayloadMaxFieldChars, + maxFieldChars, + ); + } + if (Number.isSafeInteger(totalChars)) { + sourcePayloadMaxTotalChars = Math.max( + sourcePayloadMaxTotalChars, + totalChars, + ); + } + } + return { + acceptedAutonomousToolPlanCount: protocols.length, + sourceMutationActionMax, + sourcePayloadMaxFieldChars, + sourcePayloadMaxTotalChars, + sourcePayloadPolicyViolationCount, + }; +} + +function buildSupervisorAutonomousPlayablePartialEvidence( + persistence, + { + residualSidecars = {}, + pendingActionCount = 0, + turnReport = null, + partialEvidenceReadErrorCount = 0, + } = {}, +) { + const parentTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + const parentRuntime = persistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + const childTasks = persistence.taskSnapshot.latest.filter( + (task) => + task.parentAgentId === projectSupervisorAgentId && + task.parentRunId === state.initialRunId, + ); + const childRuntimes = persistence.runtimeStates.filter( + (runtime) => + runtime.parentAgentId === projectSupervisorAgentId && + runtime.parentRunId === state.initialRunId, + ); + const deliveries = (persistence.deliveries ?? []).filter( + (delivery) => + delivery.parentAgentId === projectSupervisorAgentId && + delivery.parentRunId === state.initialRunId, + ); + const initialDeliveries = deliveries.filter( + (delivery) => delivery.repairOfDelegationId == null, + ); + const repairDeliveries = deliveries.filter( + (delivery) => delivery.repairOfDelegationId != null, + ); + const repairDeliveryIds = new Set( + repairDeliveries.map((delivery) => delivery.delegationId), + ); + const failedRepairChildCount = childTasks.filter( + (task) => isFailedTask(task) && repairDeliveryIds.has(task.delegationId), + ).length; + const failedOriginalTasks = childTasks.filter( + (task) => isFailedTask(task) && !repairDeliveryIds.has(task.delegationId), + ); + let recoveredOriginalFailureCount = 0; + for (const task of failedOriginalTasks) { + const original = initialDeliveries.find( + (delivery) => + delivery.delegationId === task.delegationId && + delivery.targetAgentId === task.agentId && + delivery.targetSessionId === task.sessionId && + delivery.targetRunId === task.runId, + ); + if ( + original && + repairDeliveries.some( + (repair) => + repair.repairOfDelegationId === original.delegationId && + repair.terminalStatus === 'completed' && + repair.structuredResult?.contractStatus === 'evidence-ready', + ) + ) { + recoveredOriginalFailureCount += 1; + } + } + + const relevantRunKeys = new Set(); + for (const record of [ + parentTask, + parentRuntime, + ...childTasks, + ...childRuntimes, + ]) { + if (isNonEmptyString(record?.agentId) && isNonEmptyString(record?.runId)) { + relevantRunKeys.add(`${record.agentId}\0${record.runId}`); + } + } + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + relevantRunKeys.has(`${record.agentId}\0${record.runId}`), + ); + const retryAudits = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + relevantRunKeys.has(`${record.agentId}\0${record.runId}`), + ); + const lifecycleByRequest = new Map(); + let duplicateProviderLifecycleCount = 0; + for (const record of lifecycle) { + if (!isNonEmptyString(record.requestId)) continue; + const statuses = lifecycleByRequest.get(record.requestId) ?? new Set(); + if (statuses.has(record.status)) duplicateProviderLifecycleCount += 1; + statuses.add(record.status); + lifecycleByRequest.set(record.requestId, statuses); + } + const terminalTaskCount = persistence.taskSnapshot.latest.filter((task) => + ['budget-exhausted', 'cancelled', 'completed', 'failed'].includes( + task.status, + ), + ).length; + const supervisorMessages = persistence.supervisorConversation ?? []; + const turnReportCaptured = isPlainObject(turnReport); + const turnReportOutcome = turnReportCaptured + ? supervisorAutonomousPlayableSafeTurnOutcomes.has(turnReport.outcome) + ? turnReport.outcome + : 'unknown' + : null; + const turnReportParentIdentityStable = turnReportCaptured + ? turnReport.parentAgentId === projectSupervisorAgentId && + turnReport.sessionId === supervisorSwarmSessionId && + turnReport.parentRunId === state.initialRunId + : false; + const turnReportPrivateLeakCount = turnReportCaptured + ? countExactSecrets( + Buffer.from(JSON.stringify(turnReport)), + state.supervisorAutonomousPlayable.privateValues, + ) + : null; + const sourcePayload = + summarizeSupervisorAutonomousPlayableSourcePayloadAudits( + persistence.agentDb, + ); + + return { + evidenceCompleteness: 'partial', + partialEvidenceCollected: true, + partialEvidenceReadErrorCount, + partialPrivacyScanComplete: false, + rootRunObserved: Boolean(parentTask || parentRuntime), + parentTaskStatus: supervisorAutonomousPlayableSafeLifecycleLabel( + parentTask?.status, + supervisorAutonomousPlayableSafeStatuses, + ), + parentTaskPhase: supervisorAutonomousPlayableSafeLifecycleLabel( + parentTask?.phase, + supervisorAutonomousPlayableSafePhases, + ), + parentRuntimeStatus: supervisorAutonomousPlayableSafeLifecycleLabel( + parentRuntime?.status, + supervisorAutonomousPlayableSafeStatuses, + ), + parentRuntimePhase: supervisorAutonomousPlayableSafeLifecycleLabel( + parentRuntime?.phase, + supervisorAutonomousPlayableSafePhases, + ), + childTaskStatusCounts: countSupervisorAutonomousPlayableLifecycleLabels( + childTasks, + 'status', + supervisorAutonomousPlayableSafeStatuses, + ), + childTaskPhaseCounts: countSupervisorAutonomousPlayableLifecycleLabels( + childTasks, + 'phase', + supervisorAutonomousPlayableSafePhases, + ), + childRuntimeStatusCounts: countSupervisorAutonomousPlayableLifecycleLabels( + childRuntimes, + 'status', + supervisorAutonomousPlayableSafeStatuses, + ), + childRuntimePhaseCounts: countSupervisorAutonomousPlayableLifecycleLabels( + childRuntimes, + 'phase', + supervisorAutonomousPlayableSafePhases, + ), + initialDeliveryStatusCounts: + countSupervisorAutonomousPlayableLifecycleLabels( + initialDeliveries, + 'status', + supervisorAutonomousPlayableSafeDeliveryStatuses, + ), + repairDeliveryStatusCounts: + countSupervisorAutonomousPlayableLifecycleLabels( + repairDeliveries, + 'status', + supervisorAutonomousPlayableSafeDeliveryStatuses, + ), + repairTerminalStatusCounts: + countSupervisorAutonomousPlayableLifecycleLabels( + repairDeliveries, + 'terminalStatus', + supervisorAutonomousPlayableSafeTerminalStatuses, + ), + failedOriginalChildCount: failedOriginalTasks.length, + failedRepairChildCount, + recoveredSpecialistFailureCount: recoveredOriginalFailureCount, + recoveredOriginalFailureCount, + awaitingRepairCount: + failedOriginalTasks.length - recoveredOriginalFailureCount, + stdinTaskCount: state.supervisorAutonomousPlayable.stdinWriteCount, + stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, + stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, + taskSha256: state.initialTask?.sha256 ?? null, + turnReportCaptured, + turnReportParentIdentityStable, + turnReportNewAssistantMessageCount: + supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'newAssistantMessageCount', + ), + turnReportFinalReplyChars: supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'finalReplyChars', + ), + turnReportPrivateLeakCount, + turnReportOutcome, + turnReportRuntimeCount: supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'runtimeCount', + ), + turnReportBusyRuntimeCount: supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'busyRuntimeCount', + ), + turnReportPendingTaskCount: supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'pendingTaskCount', + ), + turnReportRunningTaskCount: supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'runningTaskCount', + ), + turnReportWaitingForConfirmationCount: + supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'waitingForConfirmationCount', + ), + turnReportWaitingForUserInputCount: + supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'waitingForUserInputCount', + ), + turnReportReconciliationAgentCount: + supervisorAutonomousPlayableSafeTurnCount( + turnReport, + 'reconciliationAgentCount', + ), + taskCount: persistence.taskSnapshot.latest.length, + terminalTaskCount, + childTaskCount: childTasks.length, + runtimeCount: persistence.runtimeStates.length, + childRuntimeCount: childRuntimes.length, + finalSupervisorAssistantCount: supervisorMessages.filter( + (message) => message.role === 'assistant', + ).length, + supervisorUserMessageCount: supervisorMessages.filter( + (message) => message.role === 'user', + ).length, + professionalAssistantCount: ( + persistence.professionalConversations ?? [] + ).reduce( + (count, conversation) => + count + + conversation.messages.filter((message) => message.role === 'assistant') + .length, + 0, + ), + providerRequestIdentityCount: lifecycleByRequest.size, + providerLifecycleStartedCount: lifecycle.filter( + (record) => record.status === 'started', + ).length, + providerLifecycleTerminalCount: lifecycle.filter((record) => + ['completed', 'failed'].includes(record.status), + ).length, + providerLifecycleCompletedCount: lifecycle.filter( + (record) => record.status === 'completed', + ).length, + providerLifecycleFailedCount: lifecycle.filter( + (record) => record.status === 'failed', + ).length, + providerRetryAuditCount: retryAudits.length, + providerConnectivityRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'connectivity', + ).length, + providerTransportRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'transport', + ).length, + providerTimeoutRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'timeout', + ).length, + ...sourcePayload, + openProviderLifecycleCount: [...lifecycleByRequest.values()].filter( + (statuses) => + statuses.has('started') && + !statuses.has('completed') && + !statuses.has('failed'), + ).length, + duplicateProviderLifecycleCount, + pendingActionCount, + confirmationSidecarCount: residualSidecars.confirmations ?? 0, + userInputSidecarCount: residualSidecars.userInput ?? 0, + providerActionBatchSidecarCount: + residualSidecars.providerActionBatches ?? 0, + providerRetrySidecarCount: residualSidecars.providerRetries ?? 0, + providerHandoffSidecarCount: residualSidecars.providerHandoffs ?? 0, + toolPlanHandoffSidecarCount: residualSidecars.toolPlanHandoffs ?? 0, + finalizationJournalCount: residualSidecars.finalizations ?? 0, + reconciliationResidueCount: + supervisorAutonomousPlayableReconciliationResidueCount(persistence), + initialGameIndexSha256: + state.supervisorAutonomousPlayable.initialGameIndexSha256, + }; +} + +async function collectPartialSupervisorAutonomousPlayableEvidence() { + const persistence = await readSupervisorSwarmPersistence({ + tolerateErrors: true, + }); + let partialEvidenceReadErrorCount = Object.keys( + persistence.failureEvidenceErrors, + ).length; + let residualSidecars = {}; + try { + residualSidecars = + await readSupervisorAutonomousPlayableResidualSidecarCounts(); + } catch { + partialEvidenceReadErrorCount += 1; + recordError('supervisor-autonomous-playable-partial-sidecar-read-failed'); + } + let pendingActionCount = 0; + try { + pendingActionCount = (await findPendingActions()).length; + } catch { + partialEvidenceReadErrorCount += 1; + recordError('supervisor-autonomous-playable-partial-pending-read-failed'); + } + state.supervisorAutonomousPlayable.privateValues = [ + ...new Set([ + ...state.supervisorAutonomousPlayable.privateValues, + ...state.supervisorSwarm.privateValues, + ]), + ]; + return buildSupervisorAutonomousPlayablePartialEvidence(persistence, { + residualSidecars, + pendingActionCount, + turnReport: state.supervisorAutonomousPlayable.turnReport, + partialEvidenceReadErrorCount, + }); +} + +function supervisorAutonomousPlayableReconciliationResidueCount(persistence) { + const taskCount = persistence.taskSnapshot.latest.filter( + (task) => task.phase === 'needs-reconciliation', + ).length; + const runtimeCount = persistence.runtimeStates.filter( + (runtime) => runtime.phase === 'needs-reconciliation', + ).length; + const auditCount = persistence.agentDb.filter( + (record) => + record.status === 'needs-reconciliation' || + record.phase === 'needs-reconciliation' || + String(record.recordType ?? '').includes('needs_reconciliation'), + ).length; + return taskCount + runtimeCount + auditCount; +} + +function supervisorAutonomousPlayableFailureDisposition( + persistence, + { requireRecovered = false } = {}, +) { + const deliveries = supervisorSwarmParentDeliveries( + persistence.deliveries ?? [], + ); + const parentTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + const parentRuntime = persistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + assert( + !isFailedTask(parentTask ?? {}) && !isFailedTask(parentRuntime ?? {}), + 'supervisor-autonomous-playable-root-failed', + ); + const parentActive = + (parentTask && isLiveTask(parentTask)) || + [ + 'pending', + 'running', + 'waiting-for-confirmation', + 'waiting-for-user-input', + ].includes(parentRuntime?.status) || + ['queued', 'running', 'executing', 'finalizing'].includes( + parentRuntime?.phase, + ); + let recoveredOriginalFailureCount = 0; + let awaitingRepairCount = 0; + for (const task of persistence.taskSnapshot.latest.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId && + isFailedTask(candidate), + )) { + const delivery = deliveries.find( + (candidate) => + candidate.delegationId === task.delegationId && + candidate.targetAgentId === task.agentId && + candidate.targetSessionId === task.sessionId && + candidate.targetRunId === task.runId, + ); + assert( + delivery && delivery.repairOfDelegationId == null, + delivery?.repairOfDelegationId != null + ? 'supervisor-autonomous-playable-repair-child-failed' + : 'supervisor-autonomous-playable-unrecoverable-child-failed', + ); + const repairs = deliveries.filter( + (candidate) => candidate.repairOfDelegationId === delivery.delegationId, + ); + assert( + repairs.length <= 1, + 'supervisor-autonomous-playable-duplicate-repair', + ); + const repairable = + delivery.status !== 'suppressed' && + ((delivery.acceptanceCriteria?.length ?? 0) > 0 || + (delivery.expectedArtifacts?.length ?? 0) > 0) && + (delivery.structuredResult == null || + delivery.structuredResult.contractStatus === 'needs-repair'); + const recovered = repairs.some( + (repair) => + repair.terminalStatus === 'completed' && + repair.structuredResult?.contractStatus === 'evidence-ready', + ); + assert( + repairable && (recovered || parentActive || !requireRecovered), + 'supervisor-autonomous-playable-specialist-failure-unrecovered', + ); + if (recovered) recoveredOriginalFailureCount += 1; + else awaitingRepairCount += 1; + } + if (requireRecovered) { + assert( + awaitingRepairCount === 0, + 'supervisor-autonomous-playable-terminal-repair-incomplete', + ); + } + return { recoveredOriginalFailureCount, awaitingRepairCount }; +} + +function assertSupervisorAutonomousPlayableRuntimeHealthy(persistence) { + for (const task of persistence.taskSnapshot.latest) { + if (task.phase === 'needs-reconciliation') { + throw codedError( + 'supervisor-autonomous-playable-runtime-needs-reconciliation', + ); + } + } + for (const runtime of persistence.runtimeStates) { + if (runtime.phase === 'needs-reconciliation') { + throw codedError( + 'supervisor-autonomous-playable-runtime-needs-reconciliation', + ); + } + } + return supervisorAutonomousPlayableFailureDisposition(persistence); +} + +async function waitForSupervisorAutonomousPlayableDurableQuiescence() { + const deadline = Date.now() + supervisorSwarmTerminalSidecarCleanupTimeoutMs; + let quietPolls = 0; + while (Date.now() < deadline) { + const persistence = await readSupervisorSwarmPersistence(); + assertSupervisorAutonomousPlayableRuntimeHealthy(persistence); + const pending = await findPendingActions(); + const residualSidecars = + await readSupervisorAutonomousPlayableResidualSidecarCounts(); + const noLiveTasks = persistence.taskSnapshot.latest.every( + (task) => + !isLiveTask(task) && + !['waiting-for-user-input', 'waiting-for-confirmation'].includes( + task.status, + ), + ); + const noLiveRuntimes = persistence.runtimeStates.every( + (runtime) => + !isNonEmptyString(runtime.runId) || + ['completed', 'cancelled'].includes(runtime.phase), + ); + const settled = + noLiveTasks && + noLiveRuntimes && + pending.length === 0 && + Object.values(residualSidecars).every((count) => count === 0) && + supervisorAutonomousPlayableReconciliationResidueCount(persistence) === 0; + if (settled) { + quietPolls += 1; + if (quietPolls >= 3) { + const failureDisposition = + supervisorAutonomousPlayableFailureDisposition(persistence, { + requireRecovered: true, + }); + return { persistence, residualSidecars, failureDisposition }; + } + } else { + quietPolls = 0; + } + await sleep(50); + } + throw codedError('supervisor-autonomous-playable-residue-timeout'); +} + +async function validateSupervisorAutonomousPlayableEvidence( + persistence, + residualSidecars, +) { + const task = supervisorAutonomousPlayableLaneDefenseTask; + const report = state.supervisorAutonomousPlayable.turnReport; + assert( + report?.schemaVersion === 'game-creator-swarm-turn-report.v1' && + report.outcome === 'settled' && + report.parentAgentId === projectSupervisorAgentId && + report.sessionId === supervisorSwarmSessionId && + report.parentRunId === state.initialRunId && + report.runtimeCount >= 1 && + report.busyRuntimeCount === 0 && + report.pendingTaskCount === 0 && + report.runningTaskCount === 0 && + report.waitingForConfirmationCount === 0 && + report.waitingForUserInputCount === 0 && + report.newAssistantMessageCount === 1 && + report.finalReplyChars > 0 && + report.reconciliationAgentCount === 0, + 'supervisor-autonomous-playable-turn-report-invalid', + ); + + const failureDisposition = supervisorAutonomousPlayableFailureDisposition( + persistence, + { + requireRecovered: true, + }, + ); + const parentTask = persistence.taskSnapshot.latest.find( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.sessionId === supervisorSwarmSessionId && + candidate.runId === state.initialRunId, + ); + const parentRuntime = persistence.runtimeStates.find( + (candidate) => + candidate.agentId === projectSupervisorAgentId && + candidate.runId === state.initialRunId, + ); + assert( + parentTask?.task === task && + parentTask.source === 'project-supervisor-cli' && + parentTask.status === 'completed' && + parentTask.phase === 'completed' && + parentTask.runProfile === autonomousGameBuildRunProfile && + parentRuntime?.sessionId === supervisorSwarmSessionId && + parentRuntime.phase === 'completed' && + parentRuntime.pendingToolAction == null && + parentRuntime.pendingAction == null && + parentRuntime.queuedSteerCount === 0 && + parentRuntime.runProfile === autonomousGameBuildRunProfile && + persistence.taskSnapshot.latest.every( + (candidate) => + !isLiveTask(candidate) && + !['waiting-for-user-input', 'waiting-for-confirmation'].includes( + candidate.status, + ), + ) && + persistence.runtimeStates.every( + (candidate) => + candidate.phase !== 'needs-reconciliation' && + ![ + 'pending', + 'running', + 'waiting-for-user-input', + 'waiting-for-confirmation', + ].includes(candidate.status), + ), + 'supervisor-autonomous-playable-runtime-not-terminal', + ); + + const supervisorUsers = persistence.supervisorConversation.filter( + (message) => message.role === 'user', + ); + const supervisorAssistants = persistence.supervisorConversation.filter( + (message) => message.role === 'assistant', + ); + const assistant = supervisorAssistants[0]; + const professionalMessages = persistence.professionalConversations.flatMap( + (entry) => entry.messages, + ); + const isolatedMessages = persistence.isolatedConversations.flatMap( + (entry) => entry.messages, + ); + const userFacing = [ + ...persistence.supervisorConversation, + ...persistence.legacyConversation, + ]; + const allMessages = [ + ...userFacing, + ...professionalMessages, + ...isolatedMessages, + ]; + const duplicateMessageCount = duplicateCount( + allMessages.map((message) => message.messageId).filter(Boolean), + ); + const parentAssistantAudits = persistence.agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant' && + record.agentId === projectSupervisorAgentId && + record.sessionId === supervisorSwarmSessionId && + record.messageId === assistant?.messageId, + ); + const parentCompletedAudits = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.completed' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ); + assert( + supervisorUsers.length === 1 && + supervisorUsers[0].content === task && + supervisorAssistants.length === 1 && + assistant.agentId === projectSupervisorAgentId && + assistant.messageId === + finalMessageId( + projectSupervisorAgentId, + 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 && + parentAssistantAudits.length === 1 && + parentCompletedAudits.length === 1 && + duplicateMessageCount === 0, + 'supervisor-autonomous-playable-final-assistant-invalid', + ); + const finalization = validateSupervisorSwarmFinalization( + persistence.agentDb, + { + agentId: projectSupervisorAgentId, + taskId: parentTask.taskId, + sessionId: supervisorSwarmSessionId, + runId: state.initialRunId, + }, + assistant, + ); + + const actionRecords = persistence.agentDb.filter( + (record) => + isNonEmptyString(record.actionId) && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_action.observed', + 'agent.runtime.tool_observation', + 'agent.runtime.action_receipt', + ].includes(record.recordType), + ); + const actionReceipts = actionRecords.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const duplicateActionLifecycleCount = duplicateCount( + actionRecords.map(actionAuditIdentity), + ); + const duplicateReceiptCount = duplicateCount( + actionReceipts.map( + (record) => `${record.agentId}\0${record.runId}\0${record.actionId}`, + ), + ); + assert( + duplicateActionLifecycleCount === 0 && duplicateReceiptCount === 0, + 'supervisor-autonomous-playable-duplicate-action-or-receipt', + ); + + const runProfileBindings = await readSupervisorSwarmJsonDirectory( + '.agent/runtime/run-profile-bindings', + ); + const autonomousRunKeys = new Set( + runProfileBindings + .filter( + (candidate) => + candidate.profile === autonomousGameBuildRunProfile && + candidate.rootAgentId === projectSupervisorAgentId && + candidate.rootRunId === state.initialRunId, + ) + .map((candidate) => `${candidate.agentId}\0${candidate.runId}`), + ); + const sourcePayload = + summarizeSupervisorAutonomousPlayableSourcePayloadAudits( + persistence.agentDb.filter((record) => + autonomousRunKeys.has(`${record.agentId}\0${record.runId}`), + ), + ); + assert( + sourcePayload.acceptedAutonomousToolPlanCount > 0 && + sourcePayload.sourcePayloadPolicyViolationCount === 0, + 'supervisor-autonomous-playable-source-payload-policy-invalid', + ); + const matchingBindings = runProfileBindings.filter( + (binding) => + binding.agentId === projectSupervisorAgentId && + binding.runId === state.initialRunId, + ); + const binding = matchingBindings[0]; + const bindingIdentity = binding && { + schemaVersion: binding.schemaVersion, + projectId: binding.projectId, + agentId: binding.agentId, + runId: binding.runId, + rootAgentId: binding.rootAgentId, + rootRunId: binding.rootRunId, + parentAgentId: binding.parentAgentId, + parentRunId: binding.parentRunId, + source: binding.source, + profile: binding.profile, + profileFingerprint: binding.profileFingerprint, + parentBindingFingerprint: binding.parentBindingFingerprint, + boundAt: binding.boundAt, + }; + assert( + matchingBindings.length === 1 && + binding.schemaVersion === runProfileBindingSchemaVersion && + binding.rootAgentId === projectSupervisorAgentId && + binding.rootRunId === state.initialRunId && + binding.parentAgentId == null && + binding.parentRunId == null && + binding.source === 'project-supervisor-cli' && + binding.profile === autonomousGameBuildRunProfile && + binding.profileFingerprint === + hashJsonValue({ + schemaVersion: runProfileBindingSchemaVersion, + profile: autonomousGameBuildRunProfile, + }) && + binding.bindingFingerprint === hashJsonValue(bindingIdentity), + 'supervisor-autonomous-playable-run-profile-binding-invalid', + ); + + const contracts = await readSupervisorSwarmJsonDirectory( + '.agent/runtime/autonomous-completion-contracts', + ); + const receipts = await readSupervisorSwarmJsonDirectory( + '.agent/runtime/autonomous-playtest-receipts', + ); + assert( + contracts.length === 1 && receipts.length === 1, + 'supervisor-autonomous-playable-autonomous-artifact-count-invalid', + ); + const contract = contracts[0]; + const receipt = receipts[0]; + const contractIdentity = { + schemaVersion: contract.schemaVersion, + projectId: contract.projectId, + agentId: contract.agentId, + runId: contract.runId, + runProfileBindingFingerprint: contract.runProfileBindingFingerprint, + taskSha256: contract.taskSha256, + baselineRevision: contract.baselineRevision, + baselineIndexSha256: contract.baselineIndexSha256, + playtestScenario: contract.playtestScenario, + createdAt: contract.createdAt, + }; + assert( + contract.schemaVersion === autonomousCompletionContractSchemaVersion && + contract.projectId === binding.projectId && + contract.agentId === projectSupervisorAgentId && + contract.runId === state.initialRunId && + contract.runProfileBindingFingerprint === binding.bindingFingerprint && + contract.taskSha256 === hashValue(task) && + contract.baselineIndexSha256 === + state.supervisorAutonomousPlayable.initialGameIndexSha256 && + contract.playtestScenario === 'lane-defense-v1' && + contract.contractFingerprint === hashJsonValue(contractIdentity), + 'supervisor-autonomous-playable-completion-contract-invalid', + ); + + const revision = await readSupervisorSwarmProjectRevision(); + const finalGameIndex = await fs.readFile( + path.join(state.projectRoot, 'game/index.html'), + ); + const finalGameIndexSha256 = hashValue(finalGameIndex); + assert( + revision.schemaVersion === 'game-creator-project-revision.v1' && + revision.projectId === contract.projectId && + revision.revision > contract.baselineRevision && + finalGameIndexSha256 !== + state.supervisorAutonomousPlayable.initialGameIndexSha256 && + finalGameIndexSha256 !== contract.baselineIndexSha256, + 'supervisor-autonomous-playable-project-output-invalid', + ); + + const gates = ( + await readSupervisorSwarmJsonDirectory('.agent/runtime/verification') + ).filter( + (gate) => + gate.agentId === projectSupervisorAgentId && + gate.runId === state.initialRunId, + ); + 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.commandId === 'game.static_smoke' && + record.status === 'completed', + ); + assert( + gates.length === 1 && + gate.lastVerificationTool === 'game.static_smoke' && + gate.lastVerificationStatus === 'passed' && + gate.verifiedRevision === revision.revision && + staticSmokeAudits.length >= 1, + 'supervisor-autonomous-playable-static-smoke-invalid', + ); + + const receiptIdentity = { + schemaVersion: receipt.schemaVersion, + projectId: receipt.projectId, + agentId: receipt.agentId, + runId: receipt.runId, + runProfileBindingFingerprint: receipt.runProfileBindingFingerprint, + actionId: receipt.actionId, + actionFingerprint: receipt.actionFingerprint, + revision: receipt.revision, + gameIndex: receipt.gameIndex, + playtestScenario: receipt.playtestScenario, + scenarioFingerprint: receipt.scenarioFingerprint, + report: receipt.report, + screenshots: receipt.screenshots, + createdAt: receipt.createdAt, + }; + assert( + receipt.schemaVersion === autonomousPlaytestReceiptSchemaVersion && + receipt.projectId === contract.projectId && + receipt.agentId === projectSupervisorAgentId && + receipt.runId === state.initialRunId && + receipt.runProfileBindingFingerprint === binding.bindingFingerprint && + receipt.revision === revision.revision && + receipt.gameIndex.path === 'game/index.html' && + receipt.gameIndex.sha256 === finalGameIndexSha256 && + receipt.gameIndex.sizeBytes === finalGameIndex.length && + receipt.playtestScenario === 'lane-defense-v1' && + receipt.receiptFingerprint === hashJsonValue(receiptIdentity) && + actionReceipts.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.actionId === receipt.actionId && + record.actionFingerprint === receipt.actionFingerprint && + record.tool === 'preview.validate' && + record.status === 'ok', + ).length === 1, + 'supervisor-autonomous-playable-playtest-receipt-invalid', + ); + + const readDigest = async (digest, expectedSuffix) => { + assert( + isNonEmptyString(digest?.path) && + !path.isAbsolute(digest.path) && + digest.path.endsWith(expectedSuffix), + 'supervisor-autonomous-playable-evidence-path-invalid', + ); + const file = path.resolve(state.projectRoot, digest.path); + assert( + isPathInside(state.projectRoot, file), + 'supervisor-autonomous-playable-evidence-path-escape', + ); + const metadata = await fs.lstat(file); + assert( + metadata.isFile() && !metadata.isSymbolicLink(), + 'supervisor-autonomous-playable-evidence-file-invalid', + ); + const bytes = await fs.readFile(file); + assert( + hashValue(bytes) === digest.sha256 && bytes.length === digest.sizeBytes, + 'supervisor-autonomous-playable-evidence-digest-invalid', + ); + return bytes; + }; + const reportBytes = await readDigest(receipt.report, '/validation.json'); + const browserReport = JSON.parse( + decodeUtf8Fatal( + reportBytes, + 'supervisor-autonomous-playable-browser-report-invalid-utf8', + ), + ); + const screenshotEntries = await Promise.all( + receipt.screenshots.map(async (digest) => ({ + digest, + bytes: await readDigest(digest, '.png'), + })), + ); + assert( + screenshotEntries.length === 2 && + screenshotEntries.every(({ bytes }) => + bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1a\n', 'binary')), + ), + 'supervisor-autonomous-playable-screenshot-invalid', + ); + const desktop = screenshotEntries.find(({ digest }) => + digest.path.endsWith('/desktop.png'), + ); + const mobile = screenshotEntries.find(({ digest }) => + digest.path.endsWith('/mobile.png'), + ); + const playtest = browserReport.playtest; + const assertionNames = new Set( + (playtest?.assertions ?? []).map((entry) => entry.name), + ); + assert( + desktop && + mobile && + browserReport.schemaVersion === 'browser-validation.v1' && + browserReport.passed === true && + Array.isArray(browserReport.viewportResults) && + browserReport.viewportResults.length === 2 && + JSON.stringify( + browserReport.viewportResults.map((entry) => entry.viewport).sort(), + ) === JSON.stringify(['desktop', 'mobile']) && + browserReport.viewportResults.every((entry) => entry.passed === true) && + playtest?.scenario === 'lane-defense-v1' && + playtest.passed === true && + playtest.scenarioFingerprint === receipt.scenarioFingerprint && + playtest.assertions.every((entry) => entry.passed === true) && + laneDefensePlaytestRequiredAssertions.every((name) => + assertionNames.has(name), + ), + 'supervisor-autonomous-playable-lane-defense-playtest-invalid', + ); + + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle', + ); + const retryAudits = persistence.agentDb.filter( + (record) => record.recordType === 'agent.runtime.provider_request.retry', + ); + const lifecycleByRequest = new Map(); + for (const record of lifecycle) { + assert( + record.auditSchemaVersion === providerRequestLifecycleSchemaVersion && + isNonEmptyString(record.requestId), + 'supervisor-autonomous-playable-provider-lifecycle-invalid', + ); + const records = lifecycleByRequest.get(record.requestId) ?? []; + records.push(record); + lifecycleByRequest.set(record.requestId, records); + } + for (const records of lifecycleByRequest.values()) { + assert( + records.length === 2 && + records[0].status === 'started' && + ['completed', 'failed'].includes(records[1].status) && + [ + 'agentId', + 'taskId', + 'sessionId', + 'runId', + 'source', + 'requestKind', + 'requestSlot', + ].every((field) => records[0][field] === records[1][field]), + 'supervisor-autonomous-playable-provider-lifecycle-open-or-duplicate', + ); + } + assert( + lifecycleByRequest.size > 0 && + retryAudits.every( + (retry) => + lifecycleByRequest.get(retry.requestId)?.[1]?.status === 'failed' && + lifecycle.some( + (record) => + record.status === 'started' && + record.agentId === retry.agentId && + record.runId === retry.runId && + record.requestSlot === retry.nextRequestSlot, + ), + ), + 'supervisor-autonomous-playable-provider-retry-invalid', + ); + + const pendingActions = await findPendingActions(); + const reconciliationResidueCount = + supervisorAutonomousPlayableReconciliationResidueCount(persistence); + const steerRecordCount = + (await countSupervisorSwarmSteerRecords()) + + persistence.agentDb.filter((record) => + String(record.recordType ?? '').startsWith('agent.runtime.steer'), + ).length; + const approvalAuditCount = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_confirmation.approved', + ).length; + const rejectionAuditCount = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_confirmation.rejected', + ).length; + const confirmationAuditCount = persistence.agentDb.filter((record) => + String(record.recordType ?? '').includes('confirmation'), + ).length; + const userInputAuditCount = persistence.agentDb.filter((record) => + String(record.recordType ?? '').startsWith('agent.runtime.user_input.'), + ).length; + assert( + pendingActions.length === 0 && + Object.values(residualSidecars).every((count) => count === 0) && + reconciliationResidueCount === 0 && + approvalAuditCount === 0 && + rejectionAuditCount === 0 && + confirmationAuditCount === 0 && + userInputAuditCount === 0 && + steerRecordCount === 0 && + !/\[待确认\]|\[Needs input\]|输入 approve 或 reject|请选择 1-/u.test( + state.supervisorAutonomousPlayable.cliOutput, + ), + 'supervisor-autonomous-playable-intervention-or-residue-detected', + ); + + const publicLeaks = collectSupervisorSwarmPublicLeakEvidence(persistence, { + requireZero: true, + }); + state.supervisorAutonomousPlayable.privateValues = [ + ...new Set(state.supervisorSwarm.privateValues), + ]; + const absolutePaths = absolutePathVariants( + state.projectRoot, + state.options?.configDir, + state.isolatedRunner.appDataDir, + ); + let logApiKeyLeakCount = 0; + let logPrivateBodyLeakCount = 0; + let logAbsolutePathLeakCount = 0; + for (const file of await listFiles( + path.join(state.projectRoot, '.agent/logs'), + )) { + const metadata = await fs.lstat(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) continue; + const bytes = await fs.readFile(file); + logApiKeyLeakCount += countExactSecrets(bytes, state.secrets); + logPrivateBodyLeakCount += countExactSecrets( + bytes, + state.supervisorAutonomousPlayable.privateValues, + ); + logAbsolutePathLeakCount += countExactSecrets(bytes, absolutePaths); + } + const browserReportApiKeyLeakCount = countExactSecrets( + reportBytes, + state.secrets, + ); + const browserReportPrivateBodyLeakCount = countExactSecrets( + reportBytes, + state.supervisorAutonomousPlayable.privateValues, + ); + const browserReportAbsolutePathLeakCount = countExactSecrets( + reportBytes, + absolutePaths, + ); + assert( + logApiKeyLeakCount === 0 && + logPrivateBodyLeakCount === 0 && + logAbsolutePathLeakCount === 0 && + browserReportApiKeyLeakCount === 0 && + browserReportPrivateBodyLeakCount === 0 && + browserReportAbsolutePathLeakCount === 0, + 'supervisor-autonomous-playable-log-or-report-leak-detected', + ); + const lifecycleProjection = buildSupervisorAutonomousPlayablePartialEvidence( + persistence, + { + residualSidecars, + pendingActionCount: pendingActions.length, + turnReport: report, + }, + ); + + return { + ...emptySupervisorAutonomousPlayableEvidence(), + ...lifecycleProjection, + evidenceCompleteness: 'complete', + partialEvidenceCollected: false, + partialEvidenceReadErrorCount: 0, + partialPrivacyScanComplete: true, + stdinTaskCount: state.supervisorAutonomousPlayable.stdinWriteCount, + stdinEndedAfterTask: state.supervisorAutonomousPlayable.stdinEnded, + stdinBytes: state.supervisorAutonomousPlayable.stdinBytes, + taskSha256: hashValue(task), + turnReportCaptured: true, + turnReportParentIdentityStable: true, + turnReportNewAssistantMessageCount: report.newAssistantMessageCount, + turnReportFinalReplyChars: report.finalReplyChars, + turnReportPrivateLeakCount: 0, + turnReportOutcome: report.outcome, + turnReportRuntimeCount: report.runtimeCount, + turnReportBusyRuntimeCount: report.busyRuntimeCount, + turnReportPendingTaskCount: report.pendingTaskCount, + turnReportRunningTaskCount: report.runningTaskCount, + turnReportWaitingForConfirmationCount: report.waitingForConfirmationCount, + turnReportWaitingForUserInputCount: report.waitingForUserInputCount, + turnReportReconciliationAgentCount: report.reconciliationAgentCount, + taskCount: persistence.taskSnapshot.latest.length, + terminalTaskCount: persistence.taskSnapshot.latest.length, + childTaskCount: persistence.taskSnapshot.latest.filter( + (candidate) => candidate.parentRunId === state.initialRunId, + ).length, + runtimeCount: persistence.runtimeStates.length, + childRuntimeCount: persistence.runtimeStates.filter( + (candidate) => candidate.parentRunId === state.initialRunId, + ).length, + recoveredSpecialistFailureCount: + failureDisposition.recoveredOriginalFailureCount, + recoveredOriginalFailureCount: + failureDisposition.recoveredOriginalFailureCount, + finalSupervisorAssistantCount: supervisorAssistants.length, + supervisorUserMessageCount: supervisorUsers.length, + professionalAssistantCount: professionalMessages.filter( + (message) => message.role === 'assistant', + ).length, + isolatedAssistantCount: isolatedMessages.filter( + (message) => message.role === 'assistant', + ).length, + professionalUserFacingAssistantCount: 0, + completedAuditCount: parentCompletedAudits.length, + finalizationStageCount: finalization.stageCount, + providerRequestIdentityCount: lifecycleByRequest.size, + providerLifecycleStartedCount: lifecycle.filter( + (record) => record.status === 'started', + ).length, + providerLifecycleTerminalCount: lifecycle.filter((record) => + ['completed', 'failed'].includes(record.status), + ).length, + providerLifecycleCompletedCount: lifecycle.filter( + (record) => record.status === 'completed', + ).length, + providerLifecycleFailedCount: lifecycle.filter( + (record) => record.status === 'failed', + ).length, + providerRetryAuditCount: retryAudits.length, + providerConnectivityRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'connectivity', + ).length, + providerTransportRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'transport', + ).length, + providerTimeoutRetryAuditCount: retryAudits.filter( + (record) => record.errorKind === 'timeout', + ).length, + ...sourcePayload, + openProviderLifecycleCount: 0, + duplicateProviderLifecycleCount: 0, + duplicateMessageCount, + duplicateActionLifecycleCount, + duplicateReceiptCount, + runProfileBindingCount: matchingBindings.length, + baselineRevision: contract.baselineRevision, + projectRevision: revision.revision, + projectRevisionDelta: revision.revision - contract.baselineRevision, + initialGameIndexSha256: + state.supervisorAutonomousPlayable.initialGameIndexSha256, + finalGameIndexSha256, + gameIndexChanged: true, + gameIndexBytes: finalGameIndex.length, + staticSmokePassed: true, + staticSmokeAuditCount: staticSmokeAudits.length, + autonomousCompletionContractCount: contracts.length, + autonomousPlaytestReceiptCount: receipts.length, + playtestScenario: playtest.scenario, + laneDefensePlaytestPassed: true, + laneDefenseAssertionCount: playtest.assertions.length, + laneDefensePassedAssertionCount: playtest.assertions.filter( + (entry) => entry.passed, + ).length, + browserValidationPassed: true, + desktopScreenshotSha256: desktop.digest.sha256, + desktopScreenshotBytes: desktop.bytes.length, + mobileScreenshotSha256: mobile.digest.sha256, + mobileScreenshotBytes: mobile.bytes.length, + browserReportSha256: receipt.report.sha256, + browserReportBytes: reportBytes.length, + pendingActionCount: pendingActions.length, + confirmationSidecarCount: residualSidecars.confirmations, + userInputSidecarCount: residualSidecars.userInput, + providerActionBatchSidecarCount: residualSidecars.providerActionBatches, + providerRetrySidecarCount: residualSidecars.providerRetries, + providerHandoffSidecarCount: residualSidecars.providerHandoffs, + toolPlanHandoffSidecarCount: residualSidecars.toolPlanHandoffs, + finalizationJournalCount: residualSidecars.finalizations, + reconciliationResidueCount, + approvalAuditCount, + rejectionAuditCount, + userInputAuditCount, + steerRecordCount, + providerPayloadPublicLeakCount: publicLeaks.providerPayloadPublicLeakCount, + privateBodyPublicLeakCount: publicLeaks.privateBodyPublicLeakCount, + apiKeyPublicLeakCount: publicLeaks.apiKeyPublicLeakCount, + projectPathPublicLeakCount: publicLeaks.projectPathPublicLeakCount, + formalConfigPathPublicLeakCount: + publicLeaks.formalConfigPathPublicLeakCount, + logApiKeyLeakCount, + logPrivateBodyLeakCount, + logAbsolutePathLeakCount, + browserReportApiKeyLeakCount, + browserReportPrivateBodyLeakCount, + browserReportAbsolutePathLeakCount, + paths: [ + receipt.gameIndex.path, + receipt.report.path, + desktop.digest.path, + mobile.digest.path, + ], + }; +} + +async function runSupervisorAutonomousPlayableLaneDefenseE2e() { + await ensureOwnedRunnerStableKillSupport(); + await seedDisposableProject(); + 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; + + const stdin = buildSupervisorAutonomousPlayableStdin(); + const task = supervisorAutonomousPlayableLaneDefenseTask; + state.initialTask = { + chars: [...task].length, + sha256: hashValue(task), + }; + state.supervisorAutonomousPlayable.privateValues = [task]; + state.supervisorSwarm.userTask = task; + state.supervisorSwarm.privateValues = [task]; + supervisorAutonomousPlayableCliSession = startInteractiveCli([ + '--swarm-chat', + '--init', + '--autonomous-game-build', + state.projectRoot, + ]); + state.supervisorAutonomousPlayable.stdinWriteCount = 1; + state.supervisorAutonomousPlayable.stdinBytes = stdin.length; + state.supervisorAutonomousPlayable.stdinEnded = true; + supervisorAutonomousPlayableCliSession.child.stdin.end(stdin); + + const started = await waitForSupervisorAutonomousPlayableParentRuntime(task); + state.initialRunId = started.runId; + state.initialSessionId = started.sessionId; + await claimOwnedRunner(); + const report = await waitForSupervisorAutonomousPlayableCliExit( + supervisorAutonomousPlayableCliSession, + ); + supervisorAutonomousPlayableCliSession = null; + assert( + report.outcome === 'settled', + 'supervisor-autonomous-playable-turn-not-settled', + ); + + const { persistence, residualSidecars } = + await waitForSupervisorAutonomousPlayableDurableQuiescence(); + state.identityStable = true; + state.evidence = await validateSupervisorAutonomousPlayableEvidence( + persistence, + residualSidecars, + ); + assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); +} + async function runSupervisorSwarmE2e() { await ensureOwnedRunnerStableKillSupport(); await seedSupervisorSwarmDisposableProject(); @@ -16636,6 +18340,7 @@ function parseArguments(args) { suite === supervisorSwarmFinalReplyTransientRetrySuite || suite === supervisorSwarmToolPlanHandoffRunnerKillSuite || suite === supervisorSwarmAutonomousChatSuite || + suite === supervisorAutonomousPlayableLaneDefenseSuite || suite === supervisorSwarmStaticIsolatedAutonomousChatSuite || suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite || suite === steerRunnerKillSuite || @@ -16784,6 +18489,7 @@ function isolatedSuiteProtectsSourceAppData() { return ( isSupervisorSwarmTransientRetrySuite() || isSupervisorSwarmToolPlanHandoffRunnerKillSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmInteractiveChatSuite() ); } @@ -16896,6 +18602,14 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'supervisor-swarm-transient-retry-appdata', }; } + if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + return { + prefix: '.agent-runtime-real-e2e-supervisor-autonomous-playable-', + sentinelName: supervisorAutonomousPlayableAppDataSentinelFileName, + sentinelSchema: supervisorAutonomousPlayableAppDataSentinelSchema, + codePrefix: 'supervisor-autonomous-playable-appdata', + }; + } if (isSupervisorSwarmAutonomousChatSuite()) { return { prefix: '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-', @@ -18177,15 +19891,17 @@ async function removeIsolatedSuiteAppData() { async function checkPrerequisites(config) { const requiredAgents = isUserInputRuntimeSuite() ? [projectSupervisorAgentId] - : isSupervisorSwarmSuite() - ? [ - projectSupervisorAgentId, - supervisorSwarmDesignAgentId, - supervisorSwarmQualityAgentId, - ] - : isIsolatedRunnerSuite() - ? [mainAgentId] - : [mainAgentId, 'quality-review']; + : isSupervisorAutonomousPlayableLaneDefenseSuite() + ? [projectSupervisorAgentId] + : isSupervisorSwarmSuite() + ? [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ] + : isIsolatedRunnerSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; const llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( @@ -18200,9 +19916,11 @@ async function checkPrerequisites(config) { ); return { llmConfigured, - chromeAvailable: isIsolatedRunnerSuite() - ? false - : Boolean(await findSupportedBrowser()), + chromeAvailable: + !isIsolatedRunnerSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() + ? Boolean(await findSupportedBrowser()) + : false, editorApiConfigured, }; } @@ -18339,7 +20057,10 @@ async function seedDisposableProject() { path.join(state.projectRoot, 'verify-e2e.mjs'), isSupervisorSwarmSuite() ? supervisorSwarmVerificationFixtureSource() - : isGoalRuntimeSuite() || isResponseStreamSuite() || isWebSearchSuite() + : isGoalRuntimeSuite() || + isResponseStreamSuite() || + isWebSearchSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() ? goalRevisionOneVerificationFixtureSource() : goalRevisionTwoVerificationFixtureSource(), ), @@ -28101,12 +29822,16 @@ function buildSummary() { blocked: state.blocked, run: { agentId: - isUserInputRuntimeSuite() || isSupervisorSwarmSuite() + isUserInputRuntimeSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || + isSupervisorSwarmSuite() ? projectSupervisorAgentId : mainAgentId, runIdHash: hashValue(state.initialRunId), sessionIdHash: hashValue(state.initialSessionId), - runnerKilled: state.runnerKilled, + runnerKilled: isSupervisorAutonomousPlayableLaneDefenseSuite() + ? false + : state.runnerKilled, resumed: state.resumed, identityStable: state.identityStable, }, @@ -28123,6 +29848,7 @@ function buildSummary() { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ? { @@ -29198,6 +30924,148 @@ function emptySupervisorSwarmEvidence() { return buildSupervisorSwarmEvidence(); } +function emptySupervisorAutonomousPlayableEvidence() { + return { + scenario: 'project-supervisor-autonomous-playable-lane-defense', + targetAgentId: projectSupervisorAgentId, + runProfile: autonomousGameBuildRunProfile, + dedicatedZeroInterventionPath: true, + faultInjectionUsed: false, + activeRunnerKillCount: 0, + approveInputCount: 0, + answerInputCount: 0, + steerInputCount: 0, + stdinTaskCount: 0, + stdinEndedAfterTask: false, + stdinBytes: 0, + taskSha256: null, + evidenceCompleteness: 'none', + partialEvidenceCollected: false, + partialEvidenceReadErrorCount: 0, + partialPrivacyScanComplete: false, + rootRunObserved: false, + parentTaskStatus: null, + parentTaskPhase: null, + parentRuntimeStatus: null, + parentRuntimePhase: null, + childTaskStatusCounts: {}, + childTaskPhaseCounts: {}, + childRuntimeStatusCounts: {}, + childRuntimePhaseCounts: {}, + initialDeliveryStatusCounts: {}, + repairDeliveryStatusCounts: {}, + repairTerminalStatusCounts: {}, + failedOriginalChildCount: 0, + failedRepairChildCount: 0, + recoveredSpecialistFailureCount: 0, + recoveredOriginalFailureCount: 0, + awaitingRepairCount: 0, + isolatedAppDataUsed: false, + formalConfigCliCallCount: 0, + sourceRunnerEndpointUnchanged: false, + sourceAppDataDirectoryUntouched: false, + sourceConfigReplicaCount: 0, + sourceConfigReplicasVerified: false, + turnReportOutcome: null, + turnReportCaptured: false, + turnReportParentIdentityStable: false, + turnReportNewAssistantMessageCount: null, + turnReportFinalReplyChars: null, + turnReportPrivateLeakCount: null, + turnReportRuntimeCount: 0, + turnReportBusyRuntimeCount: 0, + turnReportPendingTaskCount: 0, + turnReportRunningTaskCount: 0, + turnReportWaitingForConfirmationCount: 0, + turnReportWaitingForUserInputCount: 0, + turnReportReconciliationAgentCount: 0, + taskCount: 0, + terminalTaskCount: 0, + childTaskCount: 0, + runtimeCount: 0, + childRuntimeCount: 0, + finalSupervisorAssistantCount: 0, + supervisorUserMessageCount: 0, + professionalAssistantCount: 0, + isolatedAssistantCount: 0, + professionalUserFacingAssistantCount: 0, + completedAuditCount: 0, + finalizationStageCount: 0, + providerRequestIdentityCount: 0, + providerLifecycleStartedCount: 0, + providerLifecycleTerminalCount: 0, + providerLifecycleCompletedCount: 0, + providerLifecycleFailedCount: 0, + providerRetryAuditCount: 0, + providerConnectivityRetryAuditCount: 0, + providerTransportRetryAuditCount: 0, + providerTimeoutRetryAuditCount: 0, + acceptedAutonomousToolPlanCount: 0, + sourceMutationActionMax: 0, + sourcePayloadMaxFieldChars: 0, + sourcePayloadMaxTotalChars: 0, + sourcePayloadPolicyViolationCount: 0, + openProviderLifecycleCount: 0, + duplicateProviderLifecycleCount: 0, + duplicateMessageCount: 0, + duplicateActionLifecycleCount: 0, + duplicateReceiptCount: 0, + runProfileBindingCount: 0, + baselineRevision: 0, + projectRevision: 0, + projectRevisionDelta: 0, + initialGameIndexSha256: null, + finalGameIndexSha256: null, + gameIndexChanged: false, + gameIndexBytes: 0, + staticSmokePassed: false, + staticSmokeAuditCount: 0, + autonomousCompletionContractCount: 0, + autonomousPlaytestReceiptCount: 0, + playtestScenario: null, + laneDefensePlaytestPassed: false, + laneDefenseAssertionCount: 0, + laneDefensePassedAssertionCount: 0, + browserValidationPassed: false, + desktopScreenshotSha256: null, + desktopScreenshotBytes: 0, + mobileScreenshotSha256: null, + mobileScreenshotBytes: 0, + browserReportSha256: null, + browserReportBytes: 0, + pendingActionCount: 0, + confirmationSidecarCount: 0, + userInputSidecarCount: 0, + providerActionBatchSidecarCount: 0, + providerRetrySidecarCount: 0, + providerHandoffSidecarCount: 0, + toolPlanHandoffSidecarCount: 0, + finalizationJournalCount: 0, + reconciliationResidueCount: 0, + approvalAuditCount: 0, + rejectionAuditCount: 0, + userInputAuditCount: 0, + steerRecordCount: 0, + providerPayloadPublicLeakCount: 0, + privateBodyPublicLeakCount: 0, + apiKeyPublicLeakCount: 0, + projectPathPublicLeakCount: 0, + formalConfigPathPublicLeakCount: 0, + logApiKeyLeakCount: 0, + logPrivateBodyLeakCount: 0, + logAbsolutePathLeakCount: 0, + browserReportApiKeyLeakCount: 0, + browserReportPrivateBodyLeakCount: 0, + browserReportAbsolutePathLeakCount: 0, + supervisorAutonomousPlayableReportLeakCount: 0, + supervisorAutonomousPlayableRunnerStopped: false, + supervisorAutonomousPlayableAppDataCleanupPerformed: false, + secretLeakCount: 0, + lureLeakCount: 0, + paths: [], + }; +} + function emptyParallelReadEvidence() { return { scenario: 'native-tool-plan-persistent-parallel-read-batch', @@ -30212,6 +32080,10 @@ function isSupervisorSwarmAutonomousChatSuite() { return state.suite === supervisorSwarmAutonomousChatSuite; } +function isSupervisorAutonomousPlayableLaneDefenseSuite() { + return state.suite === supervisorAutonomousPlayableLaneDefenseSuite; +} + function isSupervisorSwarmStaticIsolatedAutonomousChatSuite() { return state.suite === supervisorSwarmStaticIsolatedAutonomousChatSuite; } @@ -30264,6 +32136,7 @@ function isIsolatedRunnerSuite() { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() || isSupervisorSwarmSuite() ); } @@ -33030,7 +34903,7 @@ function countNonOverlappingTextOccurrences(text, value) { return count; } -function runAgentRuntimeRealE2eSelfTests() { +async function runAgentRuntimeRealE2eSelfTests() { const syntheticSourceEndpointLifecycleEvents = [ { phase: 'created', fileName: runnerEndpointFileName }, { phase: 'deleted', fileName: Buffer.from(runnerEndpointFileName) }, @@ -33078,6 +34951,345 @@ function runAgentRuntimeRealE2eSelfTests() { const shellPackage = JSON.parse( readFileSync(path.join(appRoot, 'package.json'), 'utf8'), ); + const previousSuiteForAutonomousPlayable = state.suite; + state.suite = supervisorAutonomousPlayableLaneDefenseSuite; + const autonomousPlayableProfile = isolatedSuiteAppDataProfile(); + const autonomousPlayableParsedArguments = parseArguments([ + '--config-dir', + path.resolve('synthetic-autonomous-playable-config'), + '--suite', + supervisorAutonomousPlayableLaneDefenseSuite, + ]); + const autonomousPlayableStdin = buildSupervisorAutonomousPlayableStdin(); + const autonomousPlayablePackageCommandsRegistered = + shellPackage.scripts?.[ + 'agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e' + ] === + 'node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense' && + rootPackage.scripts?.[ + 'ai-game-creator-shell:agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e' + ] === + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e --'; + const autonomousPlayableSuiteRegistered = + autonomousPlayableParsedArguments.suite === + supervisorAutonomousPlayableLaneDefenseSuite && + isSupervisorAutonomousPlayableLaneDefenseSuite() && + !isSupervisorSwarmSuite() && + isIsolatedRunnerSuite() && + isolatedSuiteProtectsSourceAppData() && + isolatedSuiteUsesSiblingAppData() && + autonomousPlayableProfile.sentinelName === + supervisorAutonomousPlayableAppDataSentinelFileName && + autonomousPlayableProfile.sentinelSchema === + supervisorAutonomousPlayableAppDataSentinelSchema && + autonomousPlayableStdin.equals( + Buffer.from(`${supervisorAutonomousPlayableLaneDefenseTask}\n`, 'utf8'), + ) && + autonomousPlayablePackageCommandsRegistered; + const previousInitialRunIdForAutonomousPlayable = state.initialRunId; + const previousProjectRootForAutonomousPlayable = state.projectRoot; + const previousInitialTaskForAutonomousPlayable = state.initialTask; + const previousAutonomousPlayableTurnReport = + state.supervisorAutonomousPlayable.turnReport; + const previousAutonomousPlayablePrivateValues = [ + ...state.supervisorAutonomousPlayable.privateValues, + ]; + const previousSupervisorSwarmPrivateValues = [ + ...state.supervisorSwarm.privateValues, + ]; + const previousSupervisorSwarmInitialProviderBatch = + state.supervisorSwarm.initialProviderBatch; + const autonomousPlayablePartialCanary = 'private-autonomous-partial-canary'; + const syntheticAutonomousPlayableRunId = + 'synthetic-autonomous-playable-partial-root'; + state.initialRunId = syntheticAutonomousPlayableRunId; + state.supervisorAutonomousPlayable.privateValues = [ + ...new Set([ + ...state.supervisorAutonomousPlayable.privateValues, + autonomousPlayablePartialCanary, + ]), + ]; + const syntheticAutonomousPlayablePartialEvidence = + buildSupervisorAutonomousPlayablePartialEvidence( + { + taskSnapshot: { + latest: [ + { + agentId: projectSupervisorAgentId, + runId: syntheticAutonomousPlayableRunId, + status: 'running', + phase: 'waiting-for-agent', + task: autonomousPlayablePartialCanary, + }, + { + agentId: 'code-prototype', + sessionId: 'synthetic-original-session', + runId: 'synthetic-original-run', + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + delegationId: 'synthetic-original-delivery', + status: 'failed', + phase: 'failed', + }, + { + agentId: 'code-prototype', + sessionId: 'synthetic-repair-session', + runId: 'synthetic-repair-run', + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + delegationId: 'synthetic-repair-delivery', + status: 'running', + phase: 'planning', + }, + ], + }, + runtimeStates: [ + { + agentId: projectSupervisorAgentId, + runId: syntheticAutonomousPlayableRunId, + status: 'running', + phase: 'waiting-for-agent', + currentTask: autonomousPlayablePartialCanary, + }, + { + agentId: 'code-prototype', + runId: 'synthetic-original-run', + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + status: 'failed', + phase: 'failed', + }, + { + agentId: 'code-prototype', + runId: 'synthetic-repair-run', + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + status: 'running', + phase: 'waiting-for-provider-retry', + }, + ], + deliveries: [ + { + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + delegationId: 'synthetic-original-delivery', + repairOfDelegationId: null, + targetAgentId: 'code-prototype', + targetSessionId: 'synthetic-original-session', + targetRunId: 'synthetic-original-run', + status: 'ready', + terminalStatus: 'failed', + structuredResult: { contractStatus: 'needs-repair' }, + }, + { + parentAgentId: projectSupervisorAgentId, + parentRunId: syntheticAutonomousPlayableRunId, + delegationId: 'synthetic-repair-delivery', + repairOfDelegationId: 'synthetic-original-delivery', + targetAgentId: 'code-prototype', + targetSessionId: 'synthetic-repair-session', + targetRunId: 'synthetic-repair-run', + status: 'dispatched', + terminalStatus: null, + }, + ], + agentDb: [ + { + recordType: 'agent.runtime.provider_request.lifecycle', + agentId: 'code-prototype', + runId: 'synthetic-original-run', + requestId: 'synthetic-provider-request', + status: 'started', + }, + { + recordType: 'agent.runtime.provider_request.lifecycle', + agentId: 'code-prototype', + runId: 'synthetic-original-run', + requestId: 'synthetic-provider-request', + status: 'failed', + }, + { + recordType: 'agent.runtime.provider_request.retry', + agentId: 'code-prototype', + runId: 'synthetic-original-run', + errorKind: 'transport', + }, + ], + supervisorConversation: [ + { + role: 'user', + content: autonomousPlayablePartialCanary, + }, + ], + professionalConversations: [], + failureEvidenceErrors: {}, + }, + { + residualSidecars: { providerRetries: 1 }, + pendingActionCount: 0, + turnReport: { + outcome: 'incomplete', + parentAgentId: projectSupervisorAgentId, + sessionId: supervisorSwarmSessionId, + parentRunId: syntheticAutonomousPlayableRunId, + runtimeCount: 3, + busyRuntimeCount: 2, + pendingTaskCount: 0, + runningTaskCount: 2, + waitingForConfirmationCount: 0, + waitingForUserInputCount: 0, + reconciliationAgentCount: 0, + }, + }, + ); + const autonomousPlayablePartialEvidenceValidated = + syntheticAutonomousPlayablePartialEvidence.evidenceCompleteness === + 'partial' && + syntheticAutonomousPlayablePartialEvidence.partialEvidenceCollected === + true && + syntheticAutonomousPlayablePartialEvidence.partialPrivacyScanComplete === + false && + syntheticAutonomousPlayablePartialEvidence.rootRunObserved === true && + syntheticAutonomousPlayablePartialEvidence.parentRuntimePhase === + 'waiting-for-agent' && + syntheticAutonomousPlayablePartialEvidence.childTaskStatusCounts.failed === + 1 && + syntheticAutonomousPlayablePartialEvidence.childTaskStatusCounts.running === + 1 && + syntheticAutonomousPlayablePartialEvidence.failedOriginalChildCount === 1 && + syntheticAutonomousPlayablePartialEvidence.failedRepairChildCount === 0 && + syntheticAutonomousPlayablePartialEvidence.awaitingRepairCount === 1 && + syntheticAutonomousPlayablePartialEvidence.repairDeliveryStatusCounts + .dispatched === 1 && + syntheticAutonomousPlayablePartialEvidence.providerLifecycleFailedCount === + 1 && + syntheticAutonomousPlayablePartialEvidence.providerRetryAuditCount === 1 && + syntheticAutonomousPlayablePartialEvidence.providerTransportRetryAuditCount === + 1 && + syntheticAutonomousPlayablePartialEvidence.providerRetrySidecarCount === + 1 && + syntheticAutonomousPlayablePartialEvidence.turnReportOutcome === + 'incomplete' && + syntheticAutonomousPlayablePartialEvidence.turnReportCaptured === true && + syntheticAutonomousPlayablePartialEvidence.turnReportParentIdentityStable === + true && + syntheticAutonomousPlayablePartialEvidence.turnReportPrivateLeakCount === + 0 && + !JSON.stringify(syntheticAutonomousPlayablePartialEvidence).includes( + autonomousPlayablePartialCanary, + ); + const syntheticAutonomousPlayableCollectorRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'genarrative-autonomous-partial-self-test-'), + ); + let syntheticAutonomousPlayableCollectorEvidence; + try { + state.projectRoot = syntheticAutonomousPlayableCollectorRoot; + state.initialTask = { + bytes: Buffer.byteLength(autonomousPlayablePartialCanary, 'utf8'), + sha256: hashValue(autonomousPlayablePartialCanary), + }; + state.supervisorSwarm.privateValues = [autonomousPlayablePartialCanary]; + state.supervisorSwarm.initialProviderBatch = null; + state.supervisorAutonomousPlayable.turnReport = { + outcome: 'needs-reconciliation', + parentAgentId: projectSupervisorAgentId, + sessionId: supervisorSwarmSessionId, + parentRunId: syntheticAutonomousPlayableRunId, + runtimeCount: 1, + busyRuntimeCount: 0, + pendingTaskCount: 0, + runningTaskCount: 0, + waitingForConfirmationCount: 0, + waitingForUserInputCount: 0, + reconciliationAgentCount: 1, + privateDiagnostic: autonomousPlayablePartialCanary, + }; + const taskPath = path.join( + state.projectRoot, + '.agent/runtime/tasks/project-supervisor.jsonl', + ); + const runtimePath = path.join( + state.projectRoot, + '.agent/runtime/agents/project-supervisor.json', + ); + const conversationPath = agentConversationPath( + projectSupervisorAgentId, + supervisorSwarmSessionId, + ); + await fs.mkdir(path.dirname(taskPath), { recursive: true }); + await fs.mkdir(path.dirname(runtimePath), { recursive: true }); + await fs.mkdir(path.dirname(conversationPath), { recursive: true }); + await fs.writeFile( + taskPath, + `${JSON.stringify({ + agentId: projectSupervisorAgentId, + runId: syntheticAutonomousPlayableRunId, + status: 'budget-exhausted', + phase: 'budget-exhausted', + task: autonomousPlayablePartialCanary, + })}\n`, + ); + await fs.writeFile( + runtimePath, + JSON.stringify({ + agentId: projectSupervisorAgentId, + runId: syntheticAutonomousPlayableRunId, + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + currentTask: autonomousPlayablePartialCanary, + }), + ); + await fs.writeFile( + conversationPath, + `${JSON.stringify({ + role: 'user', + content: autonomousPlayablePartialCanary, + })}\n`, + ); + syntheticAutonomousPlayableCollectorEvidence = + await collectPartialSupervisorAutonomousPlayableEvidence(); + } finally { + state.projectRoot = previousProjectRootForAutonomousPlayable; + state.initialTask = previousInitialTaskForAutonomousPlayable; + state.supervisorAutonomousPlayable.turnReport = + previousAutonomousPlayableTurnReport; + state.supervisorAutonomousPlayable.privateValues = + previousAutonomousPlayablePrivateValues; + state.supervisorSwarm.privateValues = previousSupervisorSwarmPrivateValues; + state.supervisorSwarm.initialProviderBatch = + previousSupervisorSwarmInitialProviderBatch; + await fs.rm(syntheticAutonomousPlayableCollectorRoot, { + recursive: true, + force: true, + }); + } + const autonomousPlayablePartialCollectorPrivacyValidated = + syntheticAutonomousPlayableCollectorEvidence.evidenceCompleteness === + 'partial' && + syntheticAutonomousPlayableCollectorEvidence.rootRunObserved === true && + syntheticAutonomousPlayableCollectorEvidence.parentTaskStatus === + 'budget-exhausted' && + syntheticAutonomousPlayableCollectorEvidence.parentTaskPhase === + 'budget-exhausted' && + syntheticAutonomousPlayableCollectorEvidence.parentRuntimeStatus === + 'needs-reconciliation' && + syntheticAutonomousPlayableCollectorEvidence.parentRuntimePhase === + 'needs-reconciliation' && + syntheticAutonomousPlayableCollectorEvidence.supervisorUserMessageCount === + 1 && + syntheticAutonomousPlayableCollectorEvidence.turnReportPrivateLeakCount === + 1 && + !JSON.stringify(syntheticAutonomousPlayableCollectorEvidence).includes( + autonomousPlayablePartialCanary, + ); + state.initialRunId = previousInitialRunIdForAutonomousPlayable; + state.suite = previousSuiteForAutonomousPlayable; + assert( + autonomousPlayableSuiteRegistered && + autonomousPlayablePartialEvidenceValidated && + autonomousPlayablePartialCollectorPrivacyValidated, + 'agent-runtime-real-e2e-self-test-autonomous-playable-suite-invalid', + ); const rootToolPlanHandoffCommand = 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e --'; const shellToolPlanHandoffCommand = @@ -34186,6 +36398,11 @@ function runAgentRuntimeRealE2eSelfTests() { collaborationPolicySnapshotBindingStable: true, durableSnapshotEligibilityAndContractBindingValidated: true, sourceEndpointAbsentLifecycleGuardValidated, + autonomousPlayableSuiteRegistered, + autonomousPlayablePackageCommandsRegistered, + autonomousPlayableDedicatedPathValidated: true, + autonomousPlayableExactStdinValidated: true, + autonomousPlayablePartialCollectorPrivacyValidated, toolPlanHandoffSuiteRegistered, toolPlanHandoffPackageCommandsRegistered, toolPlanHandoffSourceGuardRegistered, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 02fe199d5..21a3ffd7e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -426,7 +426,13 @@ const eventCapability = JSON.parse( ); const eventCapabilityWindows = new Set(eventCapability.windows ?? []); const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []); -for (const windowLabel of ['client', 'developer', 'main', 'launcher']) { +for (const windowLabel of [ + 'client', + 'developer', + 'main', + 'launcher', + 'supervisor-chat', +]) { if (!eventCapabilityWindows.has(windowLabel)) { throw new Error( `AI game creator shell event capability missing window: ${windowLabel}`, 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 a0f2094d7..c49c6eb80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -100,13 +100,98 @@ const AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE: &str = "agent-delegate-receipt"; const AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS: usize = 900; pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-child"; pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; +const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str = + "game-creator-run-profile-binding.v1"; +const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str = + "game-creator-autonomous-completion-contract.v1"; +const AGENT_RUNTIME_AUTONOMOUS_PLAYTEST_RECEIPT_SCHEMA_VERSION: &str = + "game-creator-autonomous-playtest-receipt.v1"; +const AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES: u64 = 4 * 1024 * 1024; +const AGENT_RUNTIME_AUTONOMOUS_BROWSER_EVIDENCE_MAX_BYTES: u64 = 16 * 1024 * 1024; +const AGENT_RUNTIME_GAME_INDEX_PATH: &str = "game/index.html"; +const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = &[ + "file.write", + "file.delete", + "project.patchset", + "project.verify", + "command.run_limited", + "preview.validate", + "agent.delegate", + "agent.spawn_isolated", + "agent.run_status", +]; pub(crate) const AGENT_RUNTIME_TASK_MAX_CHARS: usize = 4_000; #[derive(Clone, Debug, Default, Eq, PartialEq)] -struct AgentRuntimeTaskLink { - parent_agent_id: Option, - parent_run_id: Option, - delegation_id: Option, +pub(crate) struct AgentRuntimeTaskLink { + pub(crate) parent_agent_id: Option, + pub(crate) parent_run_id: Option, + pub(crate) delegation_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeRunProfileBinding { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) run_id: String, + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) parent_agent_id: Option, + pub(crate) parent_run_id: Option, + pub(crate) source: String, + pub(crate) profile: String, + pub(crate) profile_fingerprint: String, + pub(crate) parent_binding_fingerprint: Option, + pub(crate) binding_fingerprint: String, + pub(crate) bound_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeAutonomousCompletionContract { + schema_version: String, + project_id: String, + agent_id: String, + run_id: String, + run_profile_binding_fingerprint: String, + task_sha256: String, + baseline_revision: u64, + baseline_index_sha256: Option, + playtest_scenario: BrowserPlaytestScenario, + contract_fingerprint: String, + created_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeAutonomousEvidenceDigest { + path: String, + sha256: String, + size_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeAutonomousPlaytestReceipt { + schema_version: String, + project_id: String, + agent_id: String, + run_id: String, + run_profile_binding_fingerprint: String, + action_id: String, + action_fingerprint: String, + revision: u64, + game_index: AgentRuntimeAutonomousEvidenceDigest, + playtest_scenario: BrowserPlaytestScenario, + scenario_fingerprint: String, + report: AgentRuntimeAutonomousEvidenceDigest, + screenshots: Vec, + receipt_fingerprint: String, + created_at: u64, } pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { @@ -519,6 +604,28 @@ fn read_game_creator_agent_runtime_with_session_filter_at( } }; normalize_game_creator_agent_runtime_state(&mut state, &agent_id); + if !state.run_id.trim().is_empty() { + match agent_runtime_run_profile_identity_at( + root, + &state.agent_id, + &state.run_id, + Some(&state.run_profile), + Some(&state.run_profile_binding_fingerprint), + ) { + Ok((run_profile, binding_fingerprint)) => { + state.run_profile = run_profile; + state.run_profile_binding_fingerprint = binding_fingerprint; + } + Err(error) => { + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "Run Profile 绑定需要人工核对".to_string(); + state.waiting_on = "开发者核对不可变 Run Profile sidecar".to_string(); + state.next_step = "修复绑定身份后恢复当前 run".to_string(); + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + } + } + } if let Err(error) = hydrate_game_creator_agent_goal_state_at(root, &mut state) { state.status = "failed".to_string(); state.phase = "needs-reconciliation".to_string(); @@ -613,7 +720,16 @@ fn read_game_creator_agent_runtime_with_session_filter_at( state.next_step = "取消完成后可重试该任务或提交新任务".to_string(); } hydrate_game_creator_agent_runtime_context_usage_at(root, &mut state)?; - let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); + if !state.run_id.trim().is_empty() { + if let Err(error) = refresh_game_creator_agent_runtime_tool_policy(root, &mut state) { + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "Run Profile 工具策略需要人工核对".to_string(); + state.waiting_on = "开发者核对 Run Profile 与项目策略".to_string(); + state.next_step = "修复绑定或策略后恢复当前 run".to_string(); + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + } + } let recent_events = read_recent_game_creator_agent_runtime_events_for_session(&event_path, session_id)?; let task_snapshot = @@ -1709,6 +1825,10 @@ fn resume_game_creator_agent_finalization_at( &journal.run_id, ) { Some(blocker) + } else if let Some(blocker) = + autonomous_game_build_completion_blocker_at_locked(root, &state) + { + Some(blocker) } else if current_revision.revision != journal.response_revision { Some(agent_runtime_verification_blocker( "恢复时最终回复基于的项目 revision 已过期", @@ -2385,6 +2505,17 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( .map(AgentRuntimePendingActionResume::Handled); } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } if game_creator_agent_runtime_cancel_requested(root, &runtime) { cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending)?; mark_game_creator_agent_runtime_cancelled_at( @@ -2994,6 +3125,7 @@ where run_id, "agent-background-task", None, + None, ) }, )?; @@ -3020,9 +3152,37 @@ fn start_game_creator_agent_background_task_with_run_id_for_session_at( task, run_id, "agent-background-task", + None, ) } +pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( + root: &Path, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: &str, +) -> Result { + if !matches!( + source, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } + normalize_agent_runtime_run_profile(Some(run_profile))?; + start_game_creator_agent_background_task_with_source_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + task, + run_id, + source, + Some(run_profile), + ) + .map(|(result, _run_id)| result) +} + fn start_game_creator_agent_background_task_with_source_at( root: &Path, agent_id: &str, @@ -3030,9 +3190,17 @@ fn start_game_creator_agent_background_task_with_source_at( task: &str, run_id: &str, source: &str, + run_profile: Option<&str>, ) -> Result<(AgentRuntimeResult, String), String> { start_game_creator_agent_background_task_with_link_at( - root, agent_id, session_id, task, run_id, source, None, + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + None, ) } @@ -3043,6 +3211,7 @@ fn start_game_creator_agent_background_task_with_link_at( task: &str, run_id: &str, source: &str, + run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; @@ -3053,7 +3222,14 @@ fn start_game_creator_agent_background_task_with_link_at( "Agent Session Runtime 入队", || { start_game_creator_agent_background_task_with_link_in_session_lane_at( - root, &agent_id, session_id, task, run_id, source, task_link, + root, + &agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, ) }, )?; @@ -3115,6 +3291,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( task: &str, run_id: &str, source: &str, + run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { let isolated_instance = agent_id @@ -3154,6 +3331,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( task, run_id, source, + run_profile, task_link.expect("static Supervisor delegate has task link"), )? } else { @@ -3165,6 +3343,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( task, run_id, source, + run_profile, task_link, )?, true, @@ -3339,6 +3518,7 @@ pub(crate) fn start_game_creator_agent_goal_task_in_session_lane_at( run_id, "agent-background-task", None, + None, ) } @@ -3374,6 +3554,7 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at( &task_text, &run_id, "agent-ready-task-scheduler", + None, ) { Ok((result, actual_run_id)) => { append_agent_db_record( @@ -4088,6 +4269,11 @@ fn resolve_game_creator_agent_runtime_cancel_target( session_id: current_result.state.session_id.clone(), run_id: current_result.state.run_id.clone(), source: current_result.state.source.clone(), + run_profile: current_result.state.run_profile.clone(), + run_profile_binding_fingerprint: current_result + .state + .run_profile_binding_fingerprint + .clone(), parent_agent_id: current_result.state.parent_agent_id.clone(), parent_run_id: current_result.state.parent_run_id.clone(), delegation_id: current_result.state.delegation_id.clone(), @@ -4302,6 +4488,7 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( &task.task, &retry_run_id, retry_source, + Some(&task.run_profile), retry_link.as_ref(), ) }, @@ -7124,6 +7311,18 @@ fn ensure_waiting_provider_retry_records_at( == Some(runtime.agent_id.as_str()) && record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str()) + && record + .get("requestKind") + .and_then(serde_json::Value::as_str) + == Some(retry.identity.request_kind.as_str()) + && record + .get("baseRequestSlot") + .and_then(serde_json::Value::as_str) + == Some(retry.identity.base_request_slot.as_str()) + && record + .get("nextRequestSlot") + .and_then(serde_json::Value::as_str) + == Some(retry.next_request_slot.as_str()) && record .get("nextAttempt") .and_then(serde_json::Value::as_u64) @@ -7140,6 +7339,14 @@ fn ensure_waiting_provider_retry_records_at( .get("requestKind") .and_then(serde_json::Value::as_str) != Some(retry.identity.request_kind.as_str()) + || existing + .get("baseRequestSlot") + .and_then(serde_json::Value::as_str) + != Some(retry.identity.base_request_slot.as_str()) + || existing + .get("nextRequestSlot") + .and_then(serde_json::Value::as_str) + != Some(retry.next_request_slot.as_str()) || existing .get("maxRetries") .and_then(serde_json::Value::as_u64) @@ -7158,6 +7365,8 @@ fn ensure_waiting_provider_retry_records_at( "runId": runtime.run_id, "source": runtime.source, "requestKind": retry.identity.request_kind, + "baseRequestSlot": retry.identity.base_request_slot, + "nextRequestSlot": retry.next_request_slot, "nextAttempt": retry.next_attempt, "maxRetries": retry.max_retries, "backoffMs": retry.backoff_ms, @@ -7170,6 +7379,15 @@ fn ensure_waiting_provider_retry_records_at( Ok(()) } +#[cfg(test)] +pub(crate) fn ensure_waiting_provider_retry_records_for_test( + root: &Path, + runtime: &AgentRuntimeState, + retry: &AgentRuntimeProviderRetryRecord, +) -> Result<(), String> { + ensure_waiting_provider_retry_records_at(root, runtime, retry) +} + async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -7764,6 +7982,12 @@ async fn run_game_creator_agent_background_task_pass_with_context( planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint; let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint; plan = requested_plan.plan; + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !plan.response.trim().is_empty() + && agent_runtime_task_requires_read_only_delivery(&agent_id, &task) + { + plan.plan_update = agent_runtime_read_only_delivery_completion_plan_update(&runtime); + } if plan.actions.iter().any(|action| { action.tool == GAME_CREATOR_MCP_CALL_TOOL && parse_game_creator_mcp_call_input(&action.input) @@ -8045,7 +8269,8 @@ async fn run_game_creator_agent_background_task_pass_with_context( &runtime.run_id, &observations, ) - }); + }) + .or_else(|| autonomous_game_build_completion_blocker_at_locked(&root, &runtime)); if let Some(blocker) = completion_blocker { let blocker_summary = blocker.summary(); if blocker.tool == "runtime.plan_update" { @@ -8116,6 +8341,15 @@ async fn run_game_creator_agent_background_task_pass_with_context( runtime.waiting_on = "图片生成确认、配置与本地 manifest 登记".to_string(); runtime.next_step = "调用 canvas.asset_generate 生成确定路径图片,并用 asset.list 核对登记结果".to_string(); } + } else if blocker.tool == "runtime.autonomous_completion" { + runtime.status = "running".to_string(); + runtime.phase = "planning".to_string(); + runtime.current_action = "补齐自主构建可玩交付证据".to_string(); + runtime.waiting_on = + "新的 game/index.html、当前 revision 静态自检与交互试玩回执".to_string(); + runtime.next_step = + "按完成合同继续实现,依次通过 game.static_smoke 与 preview.validate" + .to_string(); } else { runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); runtime.waiting_on = "最后一次项目修改后的 project.verify、可验证 command.exec 或 game.static_smoke".to_string(); @@ -8465,7 +8699,14 @@ async fn run_game_creator_agent_background_task_pass_with_context( let batch_actions = plan.actions [action_index..action_index.saturating_add(parallel_batch_len)] .to_vec(); - if agent_runtime_parallel_read_batch_is_auto_at(&root, &agent_id, &batch_actions) { + if agent_runtime_parallel_read_batch_is_auto_at( + &root, + &agent_id, + &runtime.run_id, + &runtime.run_profile, + &runtime.run_profile_binding_fingerprint, + &batch_actions, + ) { activate_agent_runtime_plan_step( &mut runtime, action_index, @@ -8807,6 +9048,15 @@ async fn run_game_creator_agent_background_task_pass_with_context( let mut pending_action = prepared_action .take() .expect("prepared user input action exists"); + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } if let Err(error) = persist_game_creator_agent_user_input_wait_at( &root, &mut runtime, @@ -8841,8 +9091,14 @@ async fn run_game_creator_agent_background_task_pass_with_context( .is_some_and(|pending| !pending.is_auto() && pending.approved()); let local_policy_block = command_id.and_then(|command_id| { if confirmation_approved { - match game_creator_agent_runtime_tool_policy_rule(&root, &agent_id, command_id) - { + match game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + command_id, + ) { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, blocked => blocked, } @@ -10119,6 +10375,32 @@ const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_ const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str = "agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation"; const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3; +const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12; +const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16; +pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2; +const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4; +const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000; +pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600; +const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS: usize = 6_000; +pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT: usize = 4; +pub(crate) const AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT: usize = 3; +const AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX: &str = + "自主构建专业 Agent 在首次项目修改前最多允许"; +const AGENT_RUNTIME_AUTONOMOUS_PENDING_VERIFICATION_LIVENESS_ERROR_PREFIX: &str = + "自主构建专业 Agent 的最近一次项目修改仍未通过验证"; +const AGENT_RUNTIME_AUTONOMOUS_REVERIFY_AFTER_MUTATION_LIVENESS_ERROR_PREFIX: &str = + "自主构建专业 Agent 的最近一次项目修改尚未重新验证"; +const AGENT_RUNTIME_AUTONOMOUS_VERIFIED_DELIVERY_LIVENESS_ERROR_PREFIX: &str = + "自主构建专业 Agent 已取得当前 revision 的通过凭证"; +const AGENT_RUNTIME_AUTONOMOUS_FAILED_PLAYTEST_LIVENESS_ERROR_PREFIX: &str = + "自主构建 Project Supervisor 的最近一次交互试玩仍未通过"; +const AGENT_RUNTIME_AUTONOMOUS_RESPONSE_PLAN_LIVENESS_ERROR_PREFIX: &str = + "自主构建专业 Agent 已给出结论但结构化计划仍未完成"; +const AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID: &str = "quality-review"; +const AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX: &str = + "Project Supervisor 首批协作在首个 planning 窗口内未建立"; +pub(crate) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_PAYLOAD_GUIDANCE: &str = + "\n\n当前 Provider 对长时间无首字节的原生函数响应存在传输窗口。每个 planning 响应最多提交一个 file.write、file.patch、file.delete 或 project.patchset 源码写动作;单个 content、oldText 或 newText 不得超过 8000 字符,同一响应全部源码文本合计不得超过 10000 字符。创建或重做大文件时,先写入 8000 字符以内、可运行且保留扩展点的紧凑 scaffold,后续 planning 轮次再用小范围 file.patch 或 project.patchset 逐段补齐。完整写入 game/index.html 时必须包含闭合的 与 ,后续再用 patch 扩展", + source_payload.max_field_chars, + AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS + ), + )); + } + validate_agent_runtime_autonomous_response_plan_completion( + root, + agent_id, + session_id, + read_only_delivery, + &parsed.plan, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; + Some(source_payload) + } else { + None + }; + Ok((parsed, source_payload)) + }); + let parsed = match parsed { + Ok((parsed, source_payload)) + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => + { + let verification_gate = + read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + match validate_agent_runtime_autonomous_plan_liveness( + agent_id, + loop_index, + &verification_gate, + observations, + &parsed.plan, + ) { + Ok(()) => Ok((parsed, source_payload)), + Err(error) => Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + )), + } + } + parsed => parsed, + }; + let parsed = match parsed { + Ok((parsed, source_payload)) + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID => + { + let collaboration_policy = + resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)? + .policy; + let collaboration_state = + read_supervisor_collaboration_state_at(root, agent_id, run_id)?; + let preflight = preflight_supervisor_collaboration_plan( + agent_id, + &parsed.plan.actions, + &collaboration_policy, + &collaboration_state, + )?; + if let Some(violation) = preflight.violation { + Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + format!("{}:{}", violation.summary, violation.detail), + )) + } else if loop_index > AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + && supervisor_collaboration_policy_has_initial_requirements( + &collaboration_policy, + ) + && !collaboration_state.has_collaboration() + && preflight.contract.is_none() + { + Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + format!( + "{AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX};当前已到第 {loop_index} 轮,父 run 仍无协作事实,本响应也未提交满足项目 policy 的完整 agent.delegate / agent.spawn_isolated 协作批次。请在本次修复一次性建立完整首批协作,不得继续只更新计划、读取、搜索、查询状态或返回最终回复" + ), + )) + } else { + Ok((parsed, source_payload)) + } + } + parsed => parsed, + }; + match parsed { + Ok((parsed, source_payload)) => { if provider_retry::read_for_run_at(root, agent_id, run_id)?.is_some() { return Err( game_creator_agent_runtime_provider_handoff_reconciliation_error( @@ -22763,6 +24867,10 @@ async fn request_game_creator_agent_background_tool_plan_at( "normalizedTextSha256": normalized_text_sha256, "responseIdSha256": response_id_sha256, "responseIdChars": response_id_chars, + "autonomousSourcePayloadValidated": source_payload.is_some(), + "autonomousSourceMutationActionCount": source_payload.map(|value| value.mutation_action_count), + "autonomousSourceMaxFieldChars": source_payload.map(|value| value.max_field_chars), + "autonomousSourceTotalChars": source_payload.map(|value| value.total_chars), }), )?; return Ok(RequestedAgentRuntimeToolPlanOutcome::Ready(Some( @@ -22780,7 +24888,7 @@ async fn request_game_creator_agent_background_tool_plan_at( Err(error) if error.kind() == AgentRuntimeToolPlanProtocolErrorKind::CatalogBinding => { return Err(error.to_string()); } - Err(error) if repair_attempt < AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS => { + Err(error) if repair_attempt < format_repair_attempts => { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); } @@ -22814,7 +24922,7 @@ async fn request_game_creator_agent_background_tool_plan_at( "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "attempt": next_attempt, - "maxAttempts": AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS, + "maxAttempts": format_repair_attempts, "protocolErrorKind": error.kind().as_str(), "protocolErrorSha256": format!( "{:x}", @@ -22840,15 +24948,111 @@ async fn request_game_creator_agent_background_tool_plan_at( request .messages .push(LlmMessage::assistant(response_preview)); - request.messages.push(LlmMessage::user(format!( - "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式。若当前请求提供原生工具目录,请只调用 update_agent_plan、动作工具或 respond_to_user;当前 in_progress 步骤已具备执行条件时,格式修复必须保留并调用对应动作工具,不能退化为只调用 update_agent_plan。只有请求未提供 function tools 时才返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" - ))); + let force_autonomous_pre_mutation = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX) + && !request.function_tools.is_empty(); + let force_autonomous_read_only_delivery = + force_autonomous_pre_mutation && read_only_delivery; + let force_autonomous_pending_verification = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_PENDING_VERIFICATION_LIVENESS_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); + let force_autonomous_reverify_after_mutation = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_REVERIFY_AFTER_MUTATION_LIVENESS_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); + let force_autonomous_verified_delivery = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_VERIFIED_DELIVERY_LIVENESS_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); + let force_autonomous_failed_playtest = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_FAILED_PLAYTEST_LIVENESS_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); + let force_autonomous_response_plan_completion = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_RESPONSE_PLAN_LIVENESS_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); + let force_supervisor_initial_collaboration = + agent_runtime_protocol_error_requires_supervisor_collaboration_repair( + &protocol_error, + ) && !request.function_tools.is_empty(); + if force_supervisor_initial_collaboration + || force_autonomous_response_plan_completion + || force_autonomous_failed_playtest + || force_autonomous_pending_verification + || force_autonomous_reverify_after_mutation + || force_autonomous_verified_delivery + || force_autonomous_pre_mutation + { + request.function_tools = + build_agent_runtime_native_function_tools(&mcp_catalog)?; + } + if force_supervisor_initial_collaboration { + restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须根据当前 Project Supervisor 协作策略,在同一响应中一次性调用完整的 agent.delegate / agent.spawn_isolated 批次,使首批协作合同全部成立。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_response_plan_completion { + restrict_agent_runtime_autonomous_response_plan_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 update_agent_plan 与 respond_to_user。必须在同一响应先调用 update_agent_plan,保留原步骤标题并把已经真实完成的全部步骤标为 completed,再调用 respond_to_user 交付刚才已经形成的结论。不得只调用其中一个函数,不得新增步骤、继续读取、修改项目或解释。" + ))); + } else if force_autonomous_failed_playtest { + restrict_agent_runtime_autonomous_failed_playtest_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 file.write、file.patch、file.delete 与 project.patchset。必须直接修改 game/index.html,修复最近一次 preview.validate 已证明的交互、状态或可见控件故障;不得更新计划、读取、搜索、重复验证、查询状态、委派或 respond_to_user。源码仍须遵守单字段 {AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS} 字符和单轮总计 {AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS} 字符上限,优先使用小范围 file.patch。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_reverify_after_mutation { + restrict_agent_runtime_autonomous_reverification_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n最近一次失败验证后已经完成新的项目修改,旧诊断不再代表当前 revision。本次修复的原生工具目录只保留 project.verify 与 command.run_limited;必须立即验证当前 revision,不得继续更新计划、读取、搜索、查询状态、修改项目或 respond_to_user。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_pending_verification { + restrict_agent_runtime_autonomous_verification_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录已收窄。必须直接调用实际项目修改工具修复已知问题,或在项目已经满足要求时立即调用 project.verify / command.run_limited 取得当前 revision 的通过凭证。不得只更新计划、读取、搜索、查询状态或 respond_to_user。源码仍须遵守单字段 {AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS} 字符和单轮总计 {AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS} 字符上限;优先使用小范围 file.patch。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_verified_delivery { + restrict_agent_runtime_autonomous_liveness_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前 revision 已通过验证。本次修复必须直接调用 respond_to_user 交付专业合同结论;只有确有阻塞性交付缺口时才调用一个实际项目修改工具并使验证凭证失效。不得继续只更新计划、读取、搜索或查询状态。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_read_only_delivery { + restrict_agent_runtime_autonomous_read_only_delivery_repair_tools( + &mut request, + )?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前专业合同明确要求只读交付,不允许修改项目。本次修复的原生工具目录只保留 respond_to_user;必须立即调用它回传审查结论、具体缺口和证据。Runtime 会在交付终态收束当前结构化计划。不得更新计划、读取、搜索、查询状态、修改项目或解释。" + ))); + } else if force_autonomous_pre_mutation { + autonomous_scaffold_repair_active = true; + restrict_agent_runtime_autonomous_liveness_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录已收窄;必须直接调用当前提供的一个实际项目修改工具,或调用 respond_to_user 交付只读合同结论。不得只更新计划、读取、搜索、查询状态或空验证。首次 scaffold 的任一源码字段不得超过 {AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS} 字符;先保证 HTML、".to_string()); + } + let opening_end = lower_html[opening..] + .find('>') + .map(|offset| opening + offset + 1) + .ok_or_else(|| "游戏入口的 结束标签未闭合".to_string())?; + } + if next_script_tag(lower_html, cursor, true).is_some() { + return Err("游戏入口包含没有对应开始标签的 ".to_string()); + } + Ok(()) +} + pub(crate) fn validate_canvas_rendering_html(html: &str, label: &str) -> Result<(), String> { let lower_html = html.to_ascii_lowercase(); if !lower_html.contains("getcontext(") && !lower_html.contains(".getcontext") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 78b1085ac..d59162317 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -897,7 +897,8 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "viewports": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "type": "string", "enum": ["desktop", "mobile"] } }, "expectedText": string_array_schema(16), "settleMs": { "type": "integer", "minimum": 0, "maximum": 10000 }, - "failOnConsoleError": { "type": "boolean" } + "failOnConsoleError": { "type": "boolean" }, + "playtestScenario": { "type": ["string", "null"], "enum": ["generic-v1", "lane-defense-v1", null] } } }), "image.inspect" => json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index 33f6b1464..370350953 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -34,8 +34,10 @@ use chromiumoxide::page::ScreenshotParams; use chromiumoxide::Page; use futures::StreamExt; use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; use tempfile::{Builder as TempDirBuilder, NamedTempFile}; use tokio::task::JoinHandle; +use tokio::time::Instant; use url::{Host, Url}; const RESULT_SCHEMA_VERSION: &str = "browser-validation.v1"; @@ -49,6 +51,12 @@ const MAX_CAPTURED_EVENTS: usize = 100; const MAX_TRACKED_REQUESTS: usize = 2_048; const MAX_URL_CHARS: usize = 2_048; const BROWSER_TIMEOUT: Duration = Duration::from_secs(30); +const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1"; +const MAX_PLAYABLE_GAME_STATE_JSON_CHARS: usize = 128 * 1024; +const MAX_PLAYABLE_GAME_COLLECTION_ITEMS: usize = 4_096; +const MAX_PLAYABLE_GAME_ID_CHARS: usize = 256; +const PLAYTEST_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); +const PLAYTEST_POLL_INTERVAL: Duration = Duration::from_millis(50); #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] @@ -78,8 +86,24 @@ impl BrowserValidationViewport { } } +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrowserPlaytestScenario { + GenericV1, + LaneDefenseV1, +} + +impl BrowserPlaytestScenario { + fn as_str(self) -> &'static str { + match self { + Self::GenericV1 => "generic-v1", + Self::LaneDefenseV1 => "lane-defense-v1", + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct BrowserValidationInput { pub url: String, #[serde(deserialize_with = "deserialize_fixed_viewports")] @@ -90,6 +114,8 @@ pub struct BrowserValidationInput { pub settle_ms: u64, #[serde(default = "default_fail_on_console_error")] pub fail_on_console_error: bool, + #[serde(default)] + pub playtest_scenario: Option, pub evidence_root: PathBuf, } @@ -165,6 +191,88 @@ pub struct BrowserExpectedTextMatch { pub found: bool, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BrowserPlaytestPhase { + Ready, + Playing, + Won, + Lost, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrowserPlaytestAssertion { + pub name: String, + pub passed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrowserPlaytestResult { + pub scenario: BrowserPlaytestScenario, + pub scenario_fingerprint: String, + pub passed: bool, + pub initial_sequence: Option, + pub initial_phase: Option, + pub initial_level: Option, + pub final_sequence: Option, + pub final_phase: Option, + pub final_level: Option, + pub assertions: Vec, + pub diagnostics: Vec, +} + +const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[ + "state-surface-valid", + "start-control-clicked", + "start-sequence-advanced", + "start-phase-playing-or-won", + "restart-control-clicked", + "restart-sequence-advanced", + "restart-phase-ready-or-playing", +]; + +const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[ + "state-surface-valid", + "initial-phase-ready", + "level-positive", + "start-control-visible", + "start-control-enabled", + "start-control-clicked", + "start-sequence-advanced", + "start-phase-playing", + "defender-option-control-visible", + "defender-option-control-enabled", + "defender-option-control-clicked", + "defender-selection-sequence-advanced", + "defender-selection-recorded", + "lane-cell-control-visible", + "lane-cell-control-enabled", + "lane-cell-control-clicked", + "defender-placement-sequence-advanced", + "defender-count-increased", + "enemies-present-after-placement", + "speed-up-control-visible", + "speed-up-control-enabled", + "speed-up-control-clicked", + "battle-sequence-advanced", + "battle-sequence-monotonic", + "enemy-position-changed", + "enemy-health-decreased", + "phase-won", + "next-level-control-visible", + "next-level-control-enabled", + "next-level-control-clicked", + "next-level-sequence-advanced", + "level-increased", + "restart-control-visible", + "restart-control-enabled", + "restart-control-clicked", + "restart-sequence-advanced", + "restart-phase-ready-or-playing", +]; + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct BrowserConsoleMessage { @@ -316,6 +424,8 @@ pub struct BrowserValidationResult { pub browser: BrowserIdentity, pub passed: bool, pub viewport_results: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playtest: Option, pub diagnostics: Vec, pub evidence: BrowserValidationEvidencePaths, pub completed_at_unix_ms: u64, @@ -402,6 +512,246 @@ struct PageSnapshot { blocked_service_worker_count: u32, } +#[derive(Clone, Debug)] +struct PlayableWebGameState { + sequence: u64, + phase: BrowserPlaytestPhase, + level: u64, + selected_defender_id: Option, + defender_count: Option, + enemies: Option>, +} + +#[derive(Clone, Debug)] +struct PlayableEnemyState { + id: String, + position: f64, + health: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlayableStateSurfaceRead { + status: String, + content_length: usize, + content: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlaytestControlProbe { + is_html_element: bool, + visible: bool, + disabled: bool, +} + +struct PlaytestPollOutcome { + matched: bool, + last_state: Option, +} + +struct LaneBattleProgress { + baseline_sequence: u64, + previous_state: PlayableWebGameState, + sequence_advanced: bool, + sequence_monotonic: bool, + enemy_position_changed: bool, + enemy_health_decreased: bool, +} + +impl LaneBattleProgress { + fn new(initial: PlayableWebGameState) -> Self { + Self { + baseline_sequence: initial.sequence, + previous_state: initial, + sequence_advanced: false, + sequence_monotonic: true, + enemy_position_changed: false, + enemy_health_decreased: false, + } + } + + fn observe(&mut self, state: &PlayableWebGameState) { + self.sequence_advanced |= state.sequence > self.baseline_sequence; + self.sequence_monotonic &= state.sequence >= self.previous_state.sequence; + let (position_changed, health_decreased) = + lane_enemy_state_changes(&self.previous_state, state); + self.enemy_position_changed |= position_changed; + self.enemy_health_decreased |= health_decreased; + self.previous_state = state.clone(); + } + + fn completed(&self, state: &PlayableWebGameState) -> bool { + self.sequence_advanced + && self.sequence_monotonic + && self.enemy_position_changed + && self.enemy_health_decreased + && state.phase == BrowserPlaytestPhase::Won + } +} + +struct BrowserViewportValidationOutcome { + result: BrowserViewportValidationResult, + playtest: Option, +} + +impl BrowserPlaytestScenario { + fn assertion_names(self) -> &'static [&'static str] { + match self { + Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS, + Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS, + } + } +} + +impl BrowserPlaytestResult { + fn pending(scenario: BrowserPlaytestScenario) -> Self { + Self { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: false, + initial_sequence: None, + initial_phase: None, + initial_level: None, + final_sequence: None, + final_phase: None, + final_level: None, + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: false, + }) + .collect(), + diagnostics: Vec::new(), + } + } + + fn record_initial_state(&mut self, state: &PlayableWebGameState) { + self.initial_sequence = Some(state.sequence); + self.initial_phase = Some(state.phase); + self.initial_level = Some(state.level); + self.record_final_state(state); + } + + fn record_final_state(&mut self, state: &PlayableWebGameState) { + self.final_sequence = Some(state.sequence); + self.final_phase = Some(state.phase); + self.final_level = Some(state.level); + } + + fn set_assertion(&mut self, name: &str, passed: bool) { + if let Some(assertion) = self + .assertions + .iter_mut() + .find(|assertion| assertion.name == name) + { + assertion.passed = passed; + } else { + self.push_diagnostic("内部试玩断言配置无效"); + } + } + + fn push_diagnostic(&mut self, diagnostic: impl Into) { + if self.diagnostics.len() >= 32 { + return; + } + let diagnostic = truncate_chars(&diagnostic.into(), 512); + if !self.diagnostics.contains(&diagnostic) { + self.diagnostics.push(diagnostic); + } + } + + fn finish(mut self) -> Self { + let failed_assertions = self + .assertions + .iter() + .filter(|assertion| !assertion.passed) + .map(|assertion| assertion.name.clone()) + .collect::>(); + if !failed_assertions.is_empty() { + self.push_diagnostic(format!( + "未通过固定试玩断言:{}", + failed_assertions.join("、") + )); + } + self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics); + self + } +} + +fn browser_playtest_assertions_passed( + assertions: &[BrowserPlaytestAssertion], + diagnostics: &[String], +) -> bool { + !assertions.is_empty() + && assertions.iter().all(|assertion| assertion.passed) + && diagnostics.is_empty() +} + +const PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL: &str = concat!( + "surface=script#playable-web-game-state[type=application/json]\n", + "schemaVersion=playable-web-game-state.v1\n", + "base=sequence:u64,phase:ready|playing|won|lost,level:u64\n", + "lane=selectedDefenderId:null|string,defenders:array,", + "enemies:[id,lane,position,health,maxHealth],level>0\n", + "observation=action-sequence-strict,cross-observation-monotonic,", + "missing-enemy-health-zero" +); + +pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestScenario) -> String { + let mut hasher = Sha256::new(); + update_playtest_fingerprint_component(&mut hasher, "browser-playtest-scenario-fingerprint.v1"); + update_playtest_fingerprint_component(&mut hasher, scenario.as_str()); + update_playtest_fingerprint_component( + &mut hasher, + &format!( + concat!( + "viewport=desktop\n", + "click=chromiumoxide-element-mouse-input\n", + "control=unique-visible-enabled\n", + "totalTimeoutMs={}\npollIntervalMs={}" + ), + PLAYTEST_TOTAL_TIMEOUT.as_millis(), + PLAYTEST_POLL_INTERVAL.as_millis() + ), + ); + update_playtest_fingerprint_component( + &mut hasher, + PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL, + ); + update_playtest_fingerprint_component(&mut hasher, READ_PLAYABLE_GAME_STATE_SCRIPT); + update_playtest_fingerprint_component(&mut hasher, PROBE_PLAYTEST_CONTROL_SCRIPT); + match scenario { + BrowserPlaytestScenario::GenericV1 => { + update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR); + update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR); + } + BrowserPlaytestScenario::LaneDefenseV1 => { + for selector in [ + PLAYTEST_START_SELECTOR, + PLAYTEST_DEFENDER_OPTION_SELECTOR, + PLAYTEST_LANE_CELL_SELECTOR, + PLAYTEST_SPEED_UP_SELECTOR, + PLAYTEST_NEXT_LEVEL_SELECTOR, + PLAYTEST_RESTART_SELECTOR, + ] { + update_playtest_fingerprint_component(&mut hasher, selector); + } + } + } + for assertion in scenario.assertion_names() { + update_playtest_fingerprint_component(&mut hasher, assertion); + } + format!("{:x}", hasher.finalize()) +} + +fn update_playtest_fingerprint_component(hasher: &mut Sha256, component: &str) { + hasher.update((component.len() as u64).to_be_bytes()); + hasher.update(component.as_bytes()); +} + struct CaptureTasks { state: Arc>, handles: Vec>, @@ -695,10 +1045,36 @@ pub async fn validate_local_preview_in_browser( Err(_) => return Err("等待浏览器退出超时".to_string()), } result.completed_at_unix_ms = unix_time_ms(); - write_json_report(&result.evidence.report_path, &result)?; + let persisted_result = browser_validation_result_for_report(&result)?; + write_json_report(&result.evidence.report_path, &persisted_result)?; Ok(result) } +fn browser_validation_result_for_report( + result: &BrowserValidationResult, +) -> Result { + let evidence_root = &result.evidence.root; + let expected_report_path = evidence_root.join("validation.json"); + if result.evidence.report_path != expected_report_path { + return Err("浏览器验证报告路径与证据目录不匹配".to_string()); + } + + let mut persisted = result.clone(); + persisted.evidence.root = PathBuf::from("."); + persisted.evidence.report_path = PathBuf::from("validation.json"); + for viewport in &mut persisted.viewport_results { + let relative = viewport + .screenshot_path + .strip_prefix(evidence_root) + .map_err(|_| "浏览器验证截图路径不在证据目录内".to_string())?; + if relative.as_os_str().is_empty() || relative.components().count() != 1 { + return Err("浏览器验证截图路径不是证据目录内的直接文件".to_string()); + } + viewport.screenshot_path = relative.to_path_buf(); + } + Ok(persisted) +} + async fn run_browser_validation( browser: &Browser, discovered: &DiscoveredBrowser, @@ -717,11 +1093,15 @@ async fn run_browser_validation( .map_err(|error| format!("读取浏览器版本失败:{error}"))?; let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); + let mut playtest = None; for viewport in REQUIRED_VIEWPORTS { - viewport_results.push(validate_viewport(browser, preview_url, input, viewport).await?); + let outcome = validate_viewport(browser, preview_url, input, viewport).await?; + if outcome.playtest.is_some() { + playtest = outcome.playtest; + } + viewport_results.push(outcome.result); } - let passed = viewport_results.iter().all(|result| result.passed); - let diagnostics = viewport_results + let mut diagnostics = viewport_results .iter() .flat_map(|result| { result @@ -729,7 +1109,30 @@ async fn run_browser_validation( .iter() .map(move |message| format!("{}: {message}", result.viewport.file_stem())) }) - .collect(); + .collect::>(); + let playtest_passed = match (&input.playtest_scenario, &playtest) { + (None, None) => true, + (Some(_), Some(result)) => { + if !result.passed { + diagnostics.extend( + result + .diagnostics + .iter() + .map(|message| format!("playtest: {message}")), + ); + } + result.passed + } + (Some(_), None) => { + diagnostics.push("playtest: desktop 试玩结果缺失".to_string()); + false + } + (None, Some(_)) => { + diagnostics.push("playtest: 未请求试玩却产生了试玩结果".to_string()); + false + } + }; + let passed = viewport_results.iter().all(|result| result.passed) && playtest_passed; let report_path = input.evidence_root.join("validation.json"); Ok(BrowserValidationResult { @@ -742,6 +1145,7 @@ async fn run_browser_validation( }, passed, viewport_results, + playtest, diagnostics, evidence: BrowserValidationEvidencePaths { root: input.evidence_root.clone(), @@ -756,7 +1160,7 @@ async fn validate_viewport( preview_url: &Url, input: &BrowserValidationInput, viewport: BrowserValidationViewport, -) -> Result { +) -> Result { let (width, height, mobile) = viewport.dimensions(); let page = browser .new_page("about:blank") @@ -791,6 +1195,15 @@ async fn validate_viewport( } tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; + let playtest = if viewport == BrowserValidationViewport::Desktop { + match input.playtest_scenario { + Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), + None => None, + } + } else { + None + }; + let snapshot_script = build_snapshot_script(&input.expected_text)?; let snapshot: PageSnapshot = page .evaluate(snapshot_script) @@ -877,30 +1290,33 @@ async fn validate_viewport( diagnostics.push(diagnostic.to_string()); } - Ok(BrowserViewportValidationResult { - viewport, - width, - height, - final_url: sanitize_url(&snapshot.final_url), - title: truncate_chars(&snapshot.title, 512), - ready_state: snapshot.ready_state, - visible_text_summary: snapshot.visible_text_summary, - visible_text_character_count: snapshot.visible_text_character_count, - dom_character_count: snapshot.dom_character_count, - expected_text, - console_errors: capture.console_errors, - console_warnings: capture.console_warnings, - exceptions: capture.exceptions, - failed_requests: capture.failed_requests, - canvases, - blocked_popup_count: snapshot.blocked_popup_count, - blocked_dialog_count: capture.blocked_dialog_count, - blocked_download_count: snapshot.blocked_download_count, - blocked_permission_count: snapshot.blocked_permission_count, - blocked_service_worker_count: snapshot.blocked_service_worker_count, - screenshot_path, - passed: diagnostics.is_empty(), - diagnostics, + Ok(BrowserViewportValidationOutcome { + result: BrowserViewportValidationResult { + viewport, + width, + height, + final_url: sanitize_url(&snapshot.final_url), + title: truncate_chars(&snapshot.title, 512), + ready_state: snapshot.ready_state, + visible_text_summary: snapshot.visible_text_summary, + visible_text_character_count: snapshot.visible_text_character_count, + dom_character_count: snapshot.dom_character_count, + expected_text, + console_errors: capture.console_errors, + console_warnings: capture.console_warnings, + exceptions: capture.exceptions, + failed_requests: capture.failed_requests, + canvases, + blocked_popup_count: snapshot.blocked_popup_count, + blocked_dialog_count: capture.blocked_dialog_count, + blocked_download_count: snapshot.blocked_download_count, + blocked_permission_count: snapshot.blocked_permission_count, + blocked_service_worker_count: snapshot.blocked_service_worker_count, + screenshot_path, + passed: diagnostics.is_empty(), + diagnostics, + }, + playtest, }) } @@ -1407,6 +1823,830 @@ fn same_origin_url(left: &Url, right: &Url) -> bool { && left.port_or_known_default() == right.port_or_known_default() } +const READ_PLAYABLE_GAME_STATE_SCRIPT: &str = r#"(() => { + const byId = document.getElementById('playable-web-game-state'); + const exactScripts = document.querySelectorAll('script#playable-web-game-state'); + if (!byId) { + return { status: 'missing', contentLength: 0, content: null }; + } + if (!(byId instanceof HTMLScriptElement) || exactScripts.length !== 1 || exactScripts[0] !== byId) { + return { status: 'invalid-element', contentLength: 0, content: null }; + } + if (byId.getAttribute('type') !== 'application/json') { + return { status: 'invalid-type', contentLength: 0, content: null }; + } + const content = String(byId.textContent || ''); + if (content.length > 131072) { + return { status: 'too-large', contentLength: content.length, content: null }; + } + return { status: 'ok', contentLength: content.length, content }; +})()"#; + +const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { + const isHtmlElement = this instanceof HTMLElement; + if (!isHtmlElement) { + return JSON.stringify({ isHtmlElement: false, visible: false, disabled: true }); + } + + let stylesVisible = true; + let pointerBlocked = false; + for (let current = this; current instanceof HTMLElement; current = current.parentElement) { + const style = getComputedStyle(current); + const opacity = Number.parseFloat(style.opacity); + stylesVisible &&= !current.hidden + && style.display !== 'none' + && style.visibility !== 'hidden' + && style.visibility !== 'collapse' + && (Number.isNaN(opacity) || opacity > 0); + pointerBlocked ||= style.pointerEvents === 'none'; + } + + const rect = this.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const centerInViewport = centerX >= 0 + && centerY >= 0 + && centerX < window.innerWidth + && centerY < window.innerHeight; + const hit = centerInViewport ? document.elementFromPoint(centerX, centerY) : null; + const visible = this.isConnected + && stylesVisible + && rect.width > 0 + && rect.height > 0 + && this.getClientRects().length > 0 + && hit !== null + && (hit === this || this.contains(hit)); + const ariaDisabled = String(this.getAttribute('aria-disabled') || '').toLowerCase() === 'true'; + const disabled = this.matches(':disabled') + || this.hasAttribute('disabled') + || ariaDisabled + || pointerBlocked + || this.closest('[inert]') !== null; + return JSON.stringify({ isHtmlElement, visible, disabled }); +}"#; + +const PLAYTEST_START_SELECTOR: &str = r#"[data-playtest-id="start"]"#; +const PLAYTEST_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#; +const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str = r#"[data-playtest-id="defender-option"]"#; +const PLAYTEST_LANE_CELL_SELECTOR: &str = r#"[data-playtest-id="lane-cell"]"#; +const PLAYTEST_SPEED_UP_SELECTOR: &str = r#"[data-playtest-id="speed-up"]"#; +const PLAYTEST_NEXT_LEVEL_SELECTOR: &str = r#"[data-playtest-id="next-level"]"#; + +fn parse_playable_web_game_state( + content: &str, + scenario: BrowserPlaytestScenario, +) -> Result { + if content.chars().count() > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { + return Err("固定试玩状态 JSON 超过大小上限".to_string()); + } + let value = serde_json::from_str::(content).map_err(|error| { + format!( + "固定试玩状态 JSON 无效(第 {} 行,第 {} 列)", + error.line(), + error.column() + ) + })?; + let object = value + .as_object() + .ok_or_else(|| "固定试玩状态必须是 JSON object".to_string())?; + if object + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some(PLAYABLE_GAME_STATE_SCHEMA_VERSION) + { + return Err("固定试玩状态 schemaVersion 无效".to_string()); + } + let sequence = required_playable_u64(object, "sequence")?; + let level = required_playable_u64(object, "level")?; + let phase = match object.get("phase").and_then(serde_json::Value::as_str) { + Some("ready") => BrowserPlaytestPhase::Ready, + Some("playing") => BrowserPlaytestPhase::Playing, + Some("won") => BrowserPlaytestPhase::Won, + Some("lost") => BrowserPlaytestPhase::Lost, + _ => return Err("固定试玩状态 phase 无效".to_string()), + }; + + let (selected_defender_id, defender_count, enemies) = match scenario { + BrowserPlaytestScenario::GenericV1 => (None, None, None), + BrowserPlaytestScenario::LaneDefenseV1 => parse_lane_defense_playable_state(object)?, + }; + + Ok(PlayableWebGameState { + sequence, + phase, + level, + selected_defender_id, + defender_count, + enemies, + }) +} + +fn required_playable_u64( + object: &serde_json::Map, + field: &str, +) -> Result { + object + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("固定试玩状态 {field} 必须是 u64")) +} + +fn parse_lane_defense_playable_state( + object: &serde_json::Map, +) -> Result< + ( + Option, + Option, + Option>, + ), + String, +> { + let selected_defender_id = match object.get("selectedDefenderId") { + Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(value)) + if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => + { + Some(value.clone()) + } + Some(_) => { + return Err( + "lane-defense 状态 selectedDefenderId 必须是 null 或非空字符串".to_string(), + ); + } + None => return Err("lane-defense 状态缺少 selectedDefenderId".to_string()), + }; + let defenders = object + .get("defenders") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "lane-defense 状态 defenders 必须是数组".to_string())?; + if defenders.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { + return Err("lane-defense 状态 defenders 超过数量上限".to_string()); + } + let enemy_values = object + .get("enemies") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "lane-defense 状态 enemies 必须是数组".to_string())?; + if enemy_values.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { + return Err("lane-defense 状态 enemies 超过数量上限".to_string()); + } + let mut enemy_ids = HashSet::with_capacity(enemy_values.len()); + let mut enemies = Vec::with_capacity(enemy_values.len()); + for enemy_value in enemy_values { + let enemy = enemy_value + .as_object() + .ok_or_else(|| "lane-defense 状态 enemy 必须是 object".to_string())?; + let id = enemy + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| { + !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS + }) + .ok_or_else(|| "lane-defense 状态 enemy.id 必须是有界非空字符串".to_string())?; + if !enemy_ids.insert(id.to_string()) { + return Err("lane-defense 状态 enemy.id 不能重复".to_string()); + } + validate_lane_value( + enemy + .get("lane") + .ok_or_else(|| "lane-defense 状态 enemy 缺少 lane".to_string())?, + )?; + let position = required_playable_finite_number(enemy, "position")?; + let health = required_playable_finite_number(enemy, "health")?; + let max_health = required_playable_finite_number(enemy, "maxHealth")?; + if health < 0.0 || max_health <= 0.0 || health > max_health { + return Err("lane-defense 状态 enemy health/maxHealth 边界无效".to_string()); + } + enemies.push(PlayableEnemyState { + id: id.to_string(), + position, + health, + }); + } + Ok((selected_defender_id, Some(defenders.len()), Some(enemies))) +} + +fn validate_lane_value(value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::String(value) + if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => + { + Ok(()) + } + serde_json::Value::Number(value) if value.as_u64().is_some() => Ok(()), + _ => Err("lane-defense 状态 enemy.lane 必须是 u64 或有界非空字符串".to_string()), + } +} + +fn required_playable_finite_number( + object: &serde_json::Map, + field: &str, +) -> Result { + let value = object + .get(field) + .and_then(serde_json::Value::as_f64) + .ok_or_else(|| format!("lane-defense 状态 enemy.{field} 必须是数值"))?; + if !value.is_finite() { + return Err(format!("lane-defense 状态 enemy.{field} 必须是有限数值")); + } + Ok(value) +} + +async fn run_desktop_playtest( + page: &Page, + scenario: BrowserPlaytestScenario, +) -> BrowserPlaytestResult { + let mut result = BrowserPlaytestResult::pending(scenario); + let deadline = Instant::now() + PLAYTEST_TOTAL_TIMEOUT; + let execution = tokio::time::timeout( + PLAYTEST_TOTAL_TIMEOUT, + execute_desktop_playtest(page, scenario, deadline, &mut result), + ) + .await; + match execution { + Ok(Ok(())) => {} + Ok(Err(error)) => result.push_diagnostic(error), + Err(_) => result.push_diagnostic("固定试玩超过总时间上限"), + } + result.finish() +} + +async fn execute_desktop_playtest( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, + result: &mut BrowserPlaytestResult, +) -> Result<(), String> { + let initial = read_playable_web_game_state(page, scenario, deadline).await?; + result.record_initial_state(&initial); + result.set_assertion("state-surface-valid", true); + match scenario { + BrowserPlaytestScenario::GenericV1 => { + execute_generic_playtest(page, deadline, result, initial).await + } + BrowserPlaytestScenario::LaneDefenseV1 => { + execute_lane_defense_playtest(page, deadline, result, initial).await + } + } +} + +async fn execute_generic_playtest( + page: &Page, + deadline: Instant, + result: &mut BrowserPlaytestResult, + initial: PlayableWebGameState, +) -> Result<(), String> { + click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; + result.set_assertion("start-control-clicked", true); + let started = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::GenericV1, + deadline, + initial.sequence, + "start", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won + ) + }, + ) + .await?; + if let Some(state) = started.last_state.as_ref() { + result.record_final_state(state); + } + let start_sequence_advanced = started + .last_state + .as_ref() + .map(|state| state.sequence > initial.sequence) + .unwrap_or(false); + let start_phase_valid = started + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won + ) + }) + .unwrap_or(false); + result.set_assertion("start-sequence-advanced", start_sequence_advanced); + result.set_assertion("start-phase-playing-or-won", start_phase_valid); + if !started.matched { + return Err("generic-v1 start 后状态未在总时限内推进".to_string()); + } + let started_state = started + .last_state + .ok_or_else(|| "generic-v1 start 后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; + result.set_assertion("restart-control-clicked", true); + let restarted = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::GenericV1, + deadline, + started_state.sequence, + "restart", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }, + ) + .await?; + if let Some(state) = restarted.last_state.as_ref() { + result.record_final_state(state); + } + let restart_sequence_advanced = restarted + .last_state + .as_ref() + .map(|state| state.sequence > started_state.sequence) + .unwrap_or(false); + let restart_phase_valid = restarted + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }) + .unwrap_or(false); + result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); + result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); + if !restarted.matched { + return Err("generic-v1 restart 后状态未在总时限内推进".to_string()); + } + Ok(()) +} + +async fn execute_lane_defense_playtest( + page: &Page, + deadline: Instant, + result: &mut BrowserPlaytestResult, + initial: PlayableWebGameState, +) -> Result<(), String> { + let initial_phase_ready = initial.phase == BrowserPlaytestPhase::Ready; + let level_positive = initial.level > 0; + result.set_assertion("initial-phase-ready", initial_phase_ready); + result.set_assertion("level-positive", level_positive); + if !initial_phase_ready { + return Err("lane-defense-v1 初始状态必须为 ready".to_string()); + } + if !level_positive { + return Err("lane-defense-v1 初始 level 必须大于 0".to_string()); + } + + click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; + result.set_assertion("start-control-visible", true); + result.set_assertion("start-control-enabled", true); + result.set_assertion("start-control-clicked", true); + let started = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + initial.sequence, + "start", + |state| state.phase == BrowserPlaytestPhase::Playing, + ) + .await?; + if let Some(state) = started.last_state.as_ref() { + result.record_final_state(state); + } + let start_sequence_advanced = started + .last_state + .as_ref() + .map(|state| state.sequence > initial.sequence) + .unwrap_or(false); + result.set_assertion("start-sequence-advanced", start_sequence_advanced); + result.set_assertion("start-phase-playing", started.matched); + if !started.matched { + return Err("lane-defense-v1 start 后 sequence 未推进或未进入 playing".to_string()); + } + let started_state = started + .last_state + .ok_or_else(|| "lane-defense-v1 start 后未读取到状态".to_string())?; + + click_playtest_control( + page, + PLAYTEST_DEFENDER_OPTION_SELECTOR, + "defender-option", + deadline, + ) + .await?; + result.set_assertion("defender-option-control-visible", true); + result.set_assertion("defender-option-control-enabled", true); + result.set_assertion("defender-option-control-clicked", true); + let selected = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + started_state.sequence, + "defender-option", + |state| state.selected_defender_id.is_some(), + ) + .await?; + if let Some(state) = selected.last_state.as_ref() { + result.record_final_state(state); + } + let defender_selection_sequence_advanced = selected + .last_state + .as_ref() + .map(|state| state.sequence > started_state.sequence) + .unwrap_or(false); + result.set_assertion( + "defender-selection-sequence-advanced", + defender_selection_sequence_advanced, + ); + result.set_assertion("defender-selection-recorded", selected.matched); + if !selected.matched { + return Err( + "lane-defense-v1 defender-option 后 sequence 未推进或未记录选择状态".to_string(), + ); + } + let selected_state = selected + .last_state + .ok_or_else(|| "lane-defense-v1 选择后未读取到状态".to_string())?; + let defender_count_before_placement = selected_state + .defender_count + .ok_or_else(|| "lane-defense-v1 defenders 状态缺失".to_string())?; + + click_playtest_control(page, PLAYTEST_LANE_CELL_SELECTOR, "lane-cell", deadline).await?; + result.set_assertion("lane-cell-control-visible", true); + result.set_assertion("lane-cell-control-enabled", true); + result.set_assertion("lane-cell-control-clicked", true); + let placed = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + selected_state.sequence, + "lane-cell", + |state| { + state + .defender_count + .map(|count| count > defender_count_before_placement) + .unwrap_or(false) + }, + ) + .await?; + if let Some(state) = placed.last_state.as_ref() { + result.record_final_state(state); + } + let defender_placement_sequence_advanced = placed + .last_state + .as_ref() + .map(|state| state.sequence > selected_state.sequence) + .unwrap_or(false); + result.set_assertion( + "defender-placement-sequence-advanced", + defender_placement_sequence_advanced, + ); + result.set_assertion("defender-count-increased", placed.matched); + if !placed.matched { + return Err( + "lane-defense-v1 lane-cell 后 sequence 未推进或 defender 数量未增加".to_string(), + ); + } + let mut combat_state = placed + .last_state + .ok_or_else(|| "lane-defense-v1 放置后未读取到状态".to_string())?; + let mut enemies_present = combat_state + .enemies + .as_ref() + .map(|enemies| !enemies.is_empty()) + .unwrap_or(false); + if !enemies_present { + let enemies_ready = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + combat_state.sequence, + "enemy-spawn", + |state| { + state + .enemies + .as_ref() + .map(|enemies| !enemies.is_empty()) + .unwrap_or(false) + }, + ) + .await?; + if let Some(state) = enemies_ready.last_state.as_ref() { + result.record_final_state(state); + } + enemies_present = enemies_ready.matched; + if let Some(state) = enemies_ready.last_state { + combat_state = state; + } + } + result.set_assertion("enemies-present-after-placement", enemies_present); + if !enemies_present { + return Err("lane-defense-v1 放置后没有可观察 enemy".to_string()); + } + + click_playtest_control(page, PLAYTEST_SPEED_UP_SELECTOR, "speed-up", deadline).await?; + result.set_assertion("speed-up-control-visible", true); + result.set_assertion("speed-up-control-enabled", true); + result.set_assertion("speed-up-control-clicked", true); + let battle_baseline_sequence = combat_state.sequence; + let mut battle_progress = LaneBattleProgress::new(combat_state); + let completed = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + battle_baseline_sequence, + "speed-up/battle", + |state| { + battle_progress.observe(state); + battle_progress.completed(state) + }, + ) + .await?; + if let Some(state) = completed.last_state.as_ref() { + result.record_final_state(state); + } + let won = completed + .last_state + .as_ref() + .map(|state| state.phase == BrowserPlaytestPhase::Won) + .unwrap_or(false); + result.set_assertion( + "battle-sequence-advanced", + battle_progress.sequence_advanced, + ); + result.set_assertion( + "battle-sequence-monotonic", + battle_progress.sequence_monotonic, + ); + result.set_assertion( + "enemy-position-changed", + battle_progress.enemy_position_changed, + ); + result.set_assertion( + "enemy-health-decreased", + battle_progress.enemy_health_decreased, + ); + result.set_assertion("phase-won", won); + if !completed.matched { + return Err("lane-defense-v1 未在总时限内观察到战斗推进并获胜".to_string()); + } + let won_state = completed + .last_state + .ok_or_else(|| "lane-defense-v1 获胜后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_NEXT_LEVEL_SELECTOR, "next-level", deadline).await?; + result.set_assertion("next-level-control-visible", true); + result.set_assertion("next-level-control-enabled", true); + result.set_assertion("next-level-control-clicked", true); + let next_level = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + won_state.sequence, + "next-level", + |state| state.level > won_state.level, + ) + .await?; + if let Some(state) = next_level.last_state.as_ref() { + result.record_final_state(state); + } + let next_level_sequence_advanced = next_level + .last_state + .as_ref() + .map(|state| state.sequence > won_state.sequence) + .unwrap_or(false); + result.set_assertion("next-level-sequence-advanced", next_level_sequence_advanced); + result.set_assertion("level-increased", next_level.matched); + if !next_level.matched { + return Err("lane-defense-v1 next-level 后 sequence 未推进或 level 未增加".to_string()); + } + let next_level_state = next_level + .last_state + .ok_or_else(|| "lane-defense-v1 next-level 后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; + result.set_assertion("restart-control-visible", true); + result.set_assertion("restart-control-enabled", true); + result.set_assertion("restart-control-clicked", true); + let restarted = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + next_level_state.sequence, + "restart", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }, + ) + .await?; + if let Some(state) = restarted.last_state.as_ref() { + result.record_final_state(state); + } + let restart_sequence_advanced = restarted + .last_state + .as_ref() + .map(|state| state.sequence > next_level_state.sequence) + .unwrap_or(false); + let restart_phase_valid = restarted + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }) + .unwrap_or(false); + result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); + result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); + if !restarted.matched { + return Err("lane-defense-v1 restart 后状态未在总时限内推进".to_string()); + } + Ok(()) +} + +fn lane_enemy_state_changes( + previous: &PlayableWebGameState, + current: &PlayableWebGameState, +) -> (bool, bool) { + let Some(previous_enemies) = previous.enemies.as_ref() else { + return (false, false); + }; + let Some(current_enemies) = current.enemies.as_ref() else { + return (false, false); + }; + let current_by_id = current_enemies + .iter() + .map(|enemy| (enemy.id.as_str(), enemy)) + .collect::>(); + let mut position_changed = false; + let mut health_decreased = false; + for previous_enemy in previous_enemies { + match current_by_id.get(previous_enemy.id.as_str()) { + Some(enemy) => { + position_changed |= enemy.position != previous_enemy.position; + health_decreased |= enemy.health < previous_enemy.health; + } + None => { + health_decreased |= previous_enemy.health > 0.0; + } + } + } + (position_changed, health_decreased) +} + +async fn read_playable_web_game_state( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, +) -> Result { + let remaining = playtest_remaining(deadline)?; + let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) + .await + .map_err(|_| "读取固定试玩状态超时".to_string())? + .map_err(|_| "读取固定试玩状态失败".to_string())?; + let surface = evaluated + .into_value::() + .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { + return Err("固定试玩状态 JSON 超过大小上限".to_string()); + } + let content = match surface.status.as_str() { + "ok" => surface + .content + .ok_or_else(|| "固定试玩状态面缺少 JSON 正文".to_string())?, + "missing" => return Err("缺少固定试玩状态面".to_string()), + "invalid-element" => return Err("固定试玩状态面元素无效或不唯一".to_string()), + "invalid-type" => { + return Err("固定试玩状态面 type 必须是 application/json".to_string()); + } + "too-large" => return Err("固定试玩状态 JSON 超过大小上限".to_string()), + _ => return Err("固定试玩状态面读取状态无效".to_string()), + }; + parse_playable_web_game_state(&content, scenario) +} + +async fn click_playtest_control( + page: &Page, + selector: &'static str, + action: &'static str, + deadline: Instant, +) -> Result<(), String> { + let remaining = playtest_remaining(deadline)?; + let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + if elements.len() != 1 { + return Err(format!( + "固定试玩控件 {action} 必须唯一,实际数量为 {}", + elements.len() + )); + } + let element = elements + .pop() + .ok_or_else(|| format!("固定试玩控件 {action} 不存在"))?; + + let remaining = playtest_remaining(deadline)?; + tokio::time::timeout(remaining, element.scroll_into_view()) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + + let remaining = playtest_remaining(deadline)?; + let evaluated = tokio::time::timeout( + remaining, + element.call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false), + ) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + let probe = evaluated + .result + .value + .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果缺失"))?; + let probe = probe + .as_str() + .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + let probe = serde_json::from_str::(probe) + .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + if !probe.is_html_element { + return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); + } + if !probe.visible { + return Err(format!("固定试玩控件 {action} 不可见")); + } + if probe.disabled { + return Err(format!("固定试玩控件 {action} 处于 disabled 状态")); + } + + let remaining = playtest_remaining(deadline)?; + tokio::time::timeout(remaining, element.click()) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + Ok(()) +} + +async fn poll_playable_web_game_state( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, + baseline_sequence: u64, + observation: &'static str, + mut predicate: F, +) -> Result +where + F: FnMut(&PlayableWebGameState) -> bool, +{ + let mut last_state = None; + let mut previous_sequence = baseline_sequence; + loop { + if Instant::now() >= deadline { + return Ok(PlaytestPollOutcome { + matched: false, + last_state, + }); + } + let state = read_playable_web_game_state(page, scenario, deadline).await?; + if state.sequence < previous_sequence { + return Err(format!( + "固定试玩 {observation} 观察到 sequence 从 {previous_sequence} 回退到 {}", + state.sequence + )); + } + previous_sequence = state.sequence; + let matched = state.sequence > baseline_sequence && predicate(&state); + last_state = Some(state); + if matched { + return Ok(PlaytestPollOutcome { + matched: true, + last_state, + }); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(PlaytestPollOutcome { + matched: false, + last_state, + }); + } + tokio::time::sleep(std::cmp::min(PLAYTEST_POLL_INTERVAL, remaining)).await; + } +} + +fn playtest_remaining(deadline: Instant) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + Err("固定试玩超过总时间上限".to_string()) + } else { + Ok(remaining) + } +} + fn build_snapshot_script(expected_text: &[String]) -> Result { let expected = serde_json::to_string(expected_text) .map_err(|error| format!("序列化 expectedText 失败:{error}"))?; @@ -1644,6 +2884,7 @@ mod tests { expected_text: vec!["开始游戏".to_string()], settle_ms: DEFAULT_SETTLE_MS, fail_on_console_error: true, + playtest_scenario: None, evidence_root: env::temp_dir().join("browser-validation-test-evidence"), } } @@ -1667,6 +2908,21 @@ mod tests { } } + fn lane_state( + sequence: u64, + phase: BrowserPlaytestPhase, + enemies: Vec, + ) -> PlayableWebGameState { + PlayableWebGameState { + sequence, + phase, + level: 1, + selected_defender_id: None, + defender_count: Some(1), + enemies: Some(enemies), + } + } + #[test] fn validates_loopback_url_and_rejects_external_urls() { assert!(validate_input(&valid_input()).is_ok()); @@ -1763,6 +3019,273 @@ mod tests { ); assert_eq!(input.settle_ms, DEFAULT_SETTLE_MS); assert!(input.fail_on_console_error); + assert_eq!(input.playtest_scenario, None); + } + + #[test] + fn playtest_input_accepts_only_fixed_scenario_names_and_rejects_custom_controls() { + for scenario in ["generic-v1", "lane-defense-v1"] { + let input = serde_json::from_value::(serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": scenario, + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + })) + .expect("deserialize fixed playtest scenario"); + assert!(input.playtest_scenario.is_some()); + } + + for scenario in [ + serde_json::json!("custom-v1"), + serde_json::json!({"scenario": "generic-v1", "selector": "#custom"}), + serde_json::json!({"scenario": "generic-v1", "script": "alert(1)"}), + serde_json::json!({"scenario": "generic-v1", "url": "https://example.test"}), + serde_json::json!({"scenario": "generic-v1", "headers": {"x-test": "1"}}), + serde_json::json!({"scenario": "generic-v1", "cookie": "session=test"}), + serde_json::json!({"scenario": "generic-v1", "actions": ["click"]}), + ] { + let value = serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": scenario, + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + }); + assert!( + serde_json::from_value::(value).is_err(), + "accepted custom playtest scenario input {scenario}" + ); + } + + for forbidden_field in [ + "selector", + "script", + "playtestUrl", + "headers", + "cookie", + "actions", + ] { + let mut value = serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": "generic-v1", + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + }); + value[forbidden_field] = serde_json::json!("custom"); + assert!( + serde_json::from_value::(value).is_err(), + "accepted forbidden field {forbidden_field}" + ); + } + } + + #[test] + fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { + let generic = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::GenericV1); + let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); + + assert_eq!( + generic, + "dd700c57b0adb3148aecfe2ed839c2c3fa0df89be89b785bf016f4484ad3be46" + ); + assert_eq!( + lane, + "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a" + ); + assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(lane.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(generic.len(), 64); + assert_eq!(lane.len(), 64); + assert_ne!(generic, lane); + } + + #[test] + fn playable_state_accepts_all_fixed_phases_and_u64_boundaries() { + for phase in ["ready", "playing", "won", "lost"] { + let state = parse_playable_web_game_state( + &serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": u64::MAX, + "phase": phase, + "level": 0 + }) + .to_string(), + BrowserPlaytestScenario::GenericV1, + ) + .expect("parse valid generic state"); + assert_eq!(state.sequence, u64::MAX); + assert_eq!(state.level, 0); + } + + for invalid in [ + r#"{"schemaVersion":"playable-web-game-state.v0","sequence":0,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"paused","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":-1,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":1.5,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":18446744073709551616,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":-1}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1.5}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":18446744073709551616}"#, + ] { + assert!( + parse_playable_web_game_state(invalid, BrowserPlaytestScenario::GenericV1).is_err(), + "accepted invalid state {invalid}" + ); + } + } + + #[test] + fn lane_defense_state_requires_bounded_selection_defenders_and_enemy_metrics() { + let valid = serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 7, + "phase": "playing", + "level": 2, + "selectedDefenderId": null, + "defenders": [], + "enemies": [{ + "id": "enemy-1", + "lane": 0, + "position": -1.25, + "health": 5.5, + "maxHealth": 10 + }] + }); + let state = parse_playable_web_game_state( + &valid.to_string(), + BrowserPlaytestScenario::LaneDefenseV1, + ) + .expect("parse valid lane-defense state"); + assert_eq!(state.defender_count, Some(0)); + assert_eq!(state.enemies.as_ref().map(Vec::len), Some(1)); + + for invalid in [ + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "ready", "level": 0, + "defenders": [], "enemies": [] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "ready", "level": 0, + "selectedDefenderId": null, "defenders": {}, "enemies": [] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": -1, "position": 0, "health": 1, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": -1, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 2, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 0, "maxHealth": 0}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "health": 1, "maxHealth": 1}] + }), + ] { + assert!( + parse_playable_web_game_state( + &invalid.to_string(), + BrowserPlaytestScenario::LaneDefenseV1, + ) + .is_err(), + "accepted invalid lane-defense state {invalid}" + ); + } + } + + #[test] + fn lane_enemy_disappearance_counts_as_health_reaching_zero() { + let previous = lane_state( + 10, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 80.0, + health: 4.0, + }], + ); + let current = lane_state(11, BrowserPlaytestPhase::Won, Vec::new()); + + assert_eq!(lane_enemy_state_changes(&previous, ¤t), (false, true)); + } + + #[test] + fn lane_battle_progress_requires_monotonic_sequence_and_real_changes() { + let initial = lane_state( + 20, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 100.0, + health: 10.0, + }], + ); + let moved = lane_state( + 21, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 60.0, + health: 10.0, + }], + ); + let won = lane_state(22, BrowserPlaytestPhase::Won, Vec::new()); + let mut progress = LaneBattleProgress::new(initial.clone()); + progress.observe(&moved); + progress.observe(&won); + + assert!(progress.completed(&won)); + assert!(progress.sequence_advanced); + assert!(progress.sequence_monotonic); + assert!(progress.enemy_position_changed); + assert!(progress.enemy_health_decreased); + + let regressed = lane_state(19, BrowserPlaytestPhase::Won, Vec::new()); + let mut regressed_progress = LaneBattleProgress::new(initial); + regressed_progress.observe(&moved); + regressed_progress.observe(®ressed); + assert!(!regressed_progress.completed(®ressed)); + assert!(!regressed_progress.sequence_monotonic); + } + + #[test] + fn playtest_assertion_summary_requires_every_assertion_and_no_diagnostics() { + let mut assertions = BrowserPlaytestScenario::GenericV1 + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect::>(); + assert!(browser_playtest_assertions_passed(&assertions, &[])); + + assertions[0].passed = false; + assert!(!browser_playtest_assertions_passed(&assertions, &[])); + assertions[0].passed = true; + assert!(!browser_playtest_assertions_passed( + &assertions, + &["固定诊断".to_string()] + )); + assert!(!browser_playtest_assertions_passed(&[], &[])); } #[test] @@ -1841,6 +3364,7 @@ mod tests { }, passed: true, viewport_results: Vec::new(), + playtest: None, diagnostics: Vec::new(), evidence: BrowserValidationEvidencePaths { root: PathBuf::from("/tmp/evidence"), @@ -1848,13 +3372,81 @@ mod tests { }, completed_at_unix_ms: 1, }; - let value = serde_json::to_value(result).expect("serialize result"); + let value = serde_json::to_value(&result).expect("serialize result"); assert_eq!(value["schemaVersion"], RESULT_SCHEMA_VERSION); assert_eq!(value["completedAtUnixMs"], 1); assert_eq!( value["evidence"]["reportPath"], "/tmp/evidence/validation.json" ); + assert!(value.get("playtest").is_none()); + assert_eq!( + serde_json::from_value::(value) + .expect("deserialize static result") + .playtest, + None + ); + } + + #[test] + fn persisted_report_uses_only_relative_evidence_paths() { + let evidence_root = PathBuf::from("/tmp/browser-evidence"); + let result = BrowserValidationResult { + schema_version: RESULT_SCHEMA_VERSION.to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/1".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results: vec![BrowserViewportValidationResult { + viewport: BrowserValidationViewport::Desktop, + width: 1440, + height: 900, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "fixture".to_string(), + visible_text_character_count: 7, + dom_character_count: 7, + 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: evidence_root.join("desktop.png"), + passed: true, + diagnostics: Vec::new(), + }], + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + + let persisted = + browser_validation_result_for_report(&result).expect("build relative browser report"); + assert_eq!(persisted.evidence.root, PathBuf::from(".")); + assert_eq!( + persisted.evidence.report_path, + PathBuf::from("validation.json") + ); + assert_eq!( + persisted.viewport_results[0].screenshot_path, + PathBuf::from("desktop.png") + ); + assert_eq!(result.evidence.root, evidence_root); + assert!(result.viewport_results[0].screenshot_path.is_absolute()); } #[test] @@ -2049,6 +3641,7 @@ socket.onerror = () => {{ document.body.dataset.websocket = 'blocked'; }}; expected_text: vec!["Network policy probe".to_string()], settle_ms: 200, fail_on_console_error: false, + playtest_scenario: None, evidence_root: evidence.path().join("evidence"), }) .await; @@ -2098,6 +3691,237 @@ socket.onerror = () => {{ document.body.dataset.websocket = 'blocked'; }}; } } + #[tokio::test] + #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] + async fn real_chrome_lane_defense_playtest() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Lane Defense Browser Fixture + +
Lane defense fixture
+ +
+ + + + + + +
+ + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Lane defense fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::LaneDefenseV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real lane-defense browser validation"); + assert!(result.passed, "{:#?}", result.diagnostics); + assert!(result.evidence.report_path.is_file()); + assert!(result.viewport_results.iter().all(|viewport| { + viewport.passed + && viewport + .canvases + .iter() + .any(|canvas| canvas.non_empty == Some(true)) + })); + let playtest = result.playtest.expect("lane-defense playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.scenario, BrowserPlaytestScenario::LaneDefenseV1); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.initial_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.initial_level, Some(1)); + assert_eq!(playtest.final_sequence, Some(9)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_level, Some(2)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_validation_smoke() { @@ -2139,6 +3963,7 @@ socket.onerror = () => {{ document.body.dataset.websocket = 'blocked'; }}; expected_text: vec!["Expected local preview".to_string()], settle_ms: 100, fail_on_console_error: true, + playtest_scenario: None, evidence_root: evidence.path().join("evidence"), }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 80f43f2b2..8601c9ea5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -18,6 +18,7 @@ pub(crate) enum CliCommand { project_path: PathBuf, parent_agent_id: String, initialize: bool, + run_profile: String, }, AgentEnqueue { project_path: PathBuf, @@ -644,6 +645,7 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S })); } if args.first().map(String::as_str) == Some("--swarm-chat") { + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -651,10 +653,24 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } else { false }; + let autonomous_game_build = match rest + .iter() + .filter(|arg| arg.as_str() == "--autonomous-game-build") + .count() + { + 0 => false, + 1 => { + let index = rest + .iter() + .position(|arg| arg == "--autonomous-game-build") + .expect("counted autonomous game build flag"); + rest.remove(index); + true + } + _ => return Err(USAGE.to_string()), + }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { - return Err( - "用法:--swarm-chat [--init] <本地项目绝对路径> [parentAgentId]".to_string(), - ); + return Err(USAGE.to_string()); } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), @@ -663,6 +679,11 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S .map(|value| value.trim().to_string()) .unwrap_or_else(|| GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), initialize, + run_profile: if autonomous_game_build { + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string() + } else { + AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() + }, })); } if args.first().map(String::as_str) == Some("--agent-task") { @@ -876,11 +897,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { project_path, parent_agent_id, initialize, + run_profile, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; - run_game_creator_swarm_chat_at(&project_path, &parent_agent_id) + run_game_creator_swarm_chat_at(&project_path, &parent_agent_id, &run_profile) } CliCommand::AgentEnqueue { project_path, @@ -1704,6 +1726,7 @@ mod tests { project_path, parent_agent_id: "code-prototype".to_string(), initialize: true, + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), } ); assert!(command.requires_external_agent_runner()); @@ -1764,6 +1787,29 @@ mod tests { project_path, parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + } + ); + } + + #[test] + fn swarm_chat_autonomous_game_build_flag_selects_autonomous_profile() { + let project_path = std::env::current_dir().expect("current directory"); + let command = parse_cli_command(&[ + "--swarm-chat".to_string(), + project_path.display().to_string(), + "--autonomous-game-build".to_string(), + ]) + .expect("parse autonomous supervisor chat") + .expect("autonomous supervisor chat command"); + + assert_eq!( + command, + CliCommand::SwarmChat { + project_path, + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + initialize: false, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), } ); } @@ -1778,5 +1824,12 @@ mod tests { "extra".to_string(), ]) .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--autonomous-game-build".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index 25ec9f042..d01d9626b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -22,6 +22,8 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; +const AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = + ["code-prototype", "quality-review"]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -65,6 +67,70 @@ impl Default for SupervisorCollaborationPolicy { } } +fn autonomous_game_build_supervisor_collaboration_policy() -> SupervisorCollaborationPolicy { + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS.len(), + required_static_agent_ids: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS + .into_iter() + .map(str::to_string) + .collect(), + ..SupervisorCollaborationPolicy::default() + } +} + +#[derive(Clone, Debug)] +struct SupervisorCollaborationUnboundPolicy { + policy: SupervisorCollaborationPolicy, + source: &'static str, + project_policy_status: &'static str, + project_policy_present: bool, +} + +fn read_supervisor_collaboration_unbound_policy_for_run_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH); + match fs::symlink_metadata(&policy_path) { + Ok(_) => { + return Ok(SupervisorCollaborationUnboundPolicy { + policy: read_supervisor_collaboration_policy_at(root)?, + source: "project-policy-unbound", + project_policy_status: "current", + project_policy_present: true, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Project Supervisor 协作策略文件状态失败:{error}" + )); + } + } + let (run_profile, _) = + agent_runtime_run_profile_identity_at(root, parent_agent_id, parent_run_id, None, None)?; + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Ok(SupervisorCollaborationUnboundPolicy { + policy: normalize_supervisor_collaboration_policy( + autonomous_game_build_supervisor_collaboration_policy(), + )?, + source: "autonomous-run-default", + project_policy_status: "absent", + project_policy_present: false, + }); + } + Ok(SupervisorCollaborationUnboundPolicy { + policy: SupervisorCollaborationPolicy::default(), + source: "project-policy-unbound", + project_policy_status: "current", + project_policy_present: false, + }) +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct SupervisorCollaborationState { pub(crate) initial_static_agent_ids: Vec, @@ -656,7 +722,12 @@ pub(crate) fn resolve_supervisor_collaboration_policy_for_run_at( .to_string(), ); } - let policy = read_supervisor_collaboration_policy_at(root)?; + let unbound_policy = read_supervisor_collaboration_unbound_policy_for_run_at( + root, + parent_agent_id, + parent_run_id, + )?; + let policy = unbound_policy.policy.clone(); let state = read_supervisor_collaboration_state_at(root, parent_agent_id, parent_run_id)?; if state.has_collaboration() { if !game_creator_agent_runtime_run_is_non_terminal_for_collaboration_migration_at( @@ -693,8 +764,8 @@ pub(crate) fn resolve_supervisor_collaboration_policy_for_run_at( policy, snapshot_fingerprint: None, binding_source: None, - source: "project-policy-unbound", - project_policy_status: "current", + source: unbound_policy.source, + project_policy_status: unbound_policy.project_policy_status, current_project_policy_fingerprint: None, }) } @@ -703,16 +774,23 @@ fn supervisor_collaboration_policy_resolution_from_snapshot( root: &Path, snapshot: SupervisorCollaborationPolicySnapshot, ) -> Result { - let current_project_policy = read_supervisor_collaboration_policy_at(root); - let (project_policy_status, current_project_policy_fingerprint) = match current_project_policy { + let current_policy = read_supervisor_collaboration_unbound_policy_for_run_at( + root, + &snapshot.parent_agent_id, + &snapshot.parent_run_id, + ); + let (project_policy_status, current_project_policy_fingerprint) = match current_policy { Ok(current) => { - let fingerprint = supervisor_collaboration_policy_fingerprint(¤t)?; - let status = if current == snapshot.policy { + let fingerprint = current + .project_policy_present + .then(|| supervisor_collaboration_policy_fingerprint(¤t.policy)) + .transpose()?; + let status = if current.policy == snapshot.policy { "matched" } else { "drifted" }; - (status, Some(fingerprint)) + (status, fingerprint) } Err(_) => ("unreadable", None), }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fbe1159bd..c58d685b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -457,6 +457,33 @@ pub(crate) fn start_game_creator_agent_runtime_task( ) } +#[tauri::command] +pub(crate) fn start_game_creator_supervisor_runtime_task( + project_path: String, + session_id: Option, + task: String, + run_id: String, + run_profile: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + let run_profile = run_profile + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + start_game_creator_supervisor_background_task_for_session_at( + root, + session_id.as_deref(), + task.trim(), + run_id.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + run_profile, + ) +} + #[tauri::command] pub(crate) async fn compact_game_creator_agent_runtime_context( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2f046a0b3..150a3008a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -190,6 +190,10 @@ struct AgentRuntimeState { run_id: String, #[serde(default)] source: String, + #[serde(default = "default_agent_runtime_run_profile")] + run_profile: String, + #[serde(default)] + run_profile_binding_fingerprint: String, #[serde(default)] parent_agent_id: Option, #[serde(default)] @@ -307,6 +311,10 @@ struct AgentRuntimeSteerRef { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeToolPolicySnapshot { + #[serde(default = "default_agent_runtime_run_profile")] + run_profile: String, + #[serde(default)] + run_profile_binding_fingerprint: String, #[serde(default)] allowed_tools: Vec, #[serde(default)] @@ -322,6 +330,8 @@ struct AgentRuntimeToolPolicySnapshot { impl Default for AgentRuntimeToolPolicySnapshot { fn default() -> Self { Self { + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), allowed_tools: Vec::new(), auto_tools: Vec::new(), confirm_tools: Vec::new(), @@ -497,6 +507,10 @@ struct AgentRuntimeTaskRecord { run_id: String, #[serde(default)] source: String, + #[serde(default = "default_agent_runtime_run_profile")] + run_profile: String, + #[serde(default)] + run_profile_binding_fingerprint: String, #[serde(default)] parent_agent_id: Option, #[serde(default)] @@ -1166,9 +1180,17 @@ const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock"; const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1"; const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1"; +const AGENT_RUNTIME_RUN_PROFILE_STANDARD: &str = "standard"; +const AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD: &str = "autonomous-game-build"; +const AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS: usize = 8_000; +const AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS: usize = 10_000; const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20; const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12; const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12; + +fn default_agent_runtime_run_profile() -> String { + AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() +} const MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200; @@ -1695,6 +1717,7 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, start_game_creator_agent_runtime_task, + start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, read_game_creator_agent_goal, start_game_creator_agent_goal, diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index cf35426da..8ce881baf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -1906,6 +1906,8 @@ mod tests { session_id: state.session_id.clone(), run_id: state.run_id.clone(), source: state.source.clone(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), task: state.current_task.clone(), goal_id: None, goal_revision: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 17f31d963..a702b9116 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1696,6 +1696,10 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "normalizedTextSha256", "responseIdSha256", "responseIdChars", + "autonomousSourcePayloadValidated", + "autonomousSourceMutationActionCount", + "autonomousSourceMaxFieldChars", + "autonomousSourceTotalChars", ]; const REPAIR_FIELDS: &[&str] = &[ "recordType", @@ -1801,6 +1805,49 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); } } + let autonomous_source_payload_validated = record + .get("autonomousSourcePayloadValidated") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + "Agent DB tool-plan 幂等审计字段无效:autonomousSourcePayloadValidated" + .to_string() + })?; + let autonomous_source_values = [ + "autonomousSourceMutationActionCount", + "autonomousSourceMaxFieldChars", + "autonomousSourceTotalChars", + ] + .map(|field| record.get(field)); + if autonomous_source_payload_validated { + let [Some(mutation_count), Some(max_field_chars), Some(total_chars)] = + autonomous_source_values + else { + return Err("Agent DB tool-plan 自主源码载荷审计缺少数值".to_string()); + }; + let Some(mutation_count) = mutation_count.as_u64() else { + return Err("Agent DB tool-plan 自主源码动作数量无效".to_string()); + }; + let Some(max_field_chars) = max_field_chars.as_u64() else { + return Err("Agent DB tool-plan 自主源码字段长度无效".to_string()); + }; + let Some(total_chars) = total_chars.as_u64() else { + return Err("Agent DB tool-plan 自主源码总长度无效".to_string()); + }; + if mutation_count > 1 + || max_field_chars + > AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS as u64 + || total_chars + > AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS as u64 + || total_chars < max_field_chars + { + return Err("Agent DB tool-plan 自主源码载荷审计越界".to_string()); + } + } else if autonomous_source_values + .iter() + .any(|value| !matches!(value, Some(serde_json::Value::Null))) + { + return Err("非自主 tool-plan 不能携带源码载荷数值".to_string()); + } } "agent.runtime.tool_plan.repair" => { for field in ["protocolErrorSha256", "responsePreviewSha256"] { @@ -5748,7 +5795,7 @@ pub(crate) fn run_limited_local_command_at( } validate_game_html_smoke(&html)?; - let output = format!("通过:{},{} 字节", game_index_path.display(), html.len()); + let output = format!("通过:game/index.html,{} 字节", html.len()); let log_path = root.join(".agent/logs/command.log"); if let Some(parent) = log_path.parent() { fs::create_dir_all(parent) @@ -5769,7 +5816,7 @@ pub(crate) fn run_limited_local_command_at( command_id: command_id.to_string(), status: GameCreationAppCommandRunStatus::Completed, output: output.clone(), - log_path: log_path.to_string_lossy().into_owned(), + log_path: ".agent/logs/command.log".to_string(), updated_at, }, )?; @@ -5778,7 +5825,7 @@ pub(crate) fn run_limited_local_command_at( command_id: command_id.to_string(), status: "completed".to_string(), output, - log_path: log_path.to_string_lossy().into_owned(), + log_path: ".agent/logs/command.log".to_string(), updated_at, }) } @@ -8824,6 +8871,10 @@ mod agent_db_security_tests { "normalizedTextSha256": null, "responseIdSha256": null, "responseIdChars": 0, + "autonomousSourcePayloadValidated": false, + "autonomousSourceMutationActionCount": null, + "autonomousSourceMaxFieldChars": null, + "autonomousSourceTotalChars": null, }) } @@ -9725,6 +9776,37 @@ mod agent_db_security_tests { fs::remove_dir_all(root).ok(); } + #[test] + fn tool_plan_audit_accepts_current_autonomous_source_limits() { + let root = unique_agent_db_test_root("tool-plan-audit-autonomous-source-limits"); + fs::create_dir_all(&root).expect("create autonomous source audit project"); + let mut record = tool_plan_protocol_audit_record( + "code-prototype", + "autonomous-source-limit-run", + "loop-0-repair-0", + ); + record["autonomousSourcePayloadValidated"] = serde_json::json!(true); + record["autonomousSourceMutationActionCount"] = serde_json::json!(1); + record["autonomousSourceMaxFieldChars"] = + serde_json::json!(AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS); + record["autonomousSourceTotalChars"] = + serde_json::json!(AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS); + + assert!( + append_agent_db_tool_plan_audit_idempotent(&root, record.clone()) + .expect("append current autonomous source limits") + ); + + record["requestSlot"] = serde_json::json!("loop-1-repair-0"); + record["autonomousSourceMaxFieldChars"] = + serde_json::json!(AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS + 1); + let error = append_agent_db_tool_plan_audit_idempotent(&root, record) + .expect_err("reject autonomous source field above current limit"); + assert_eq!(error, "Agent DB tool-plan 自主源码载荷审计越界"); + + fs::remove_dir_all(root).ok(); + } + #[test] fn action_group_append_repairs_torn_tail_and_scans_past_bounded_history() { const RECORD_TYPE: &str = "agent.runtime.agent.isolated_join.claimed_by_parent"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f8a60d598..0b16b09be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -33,7 +33,7 @@ const EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10); const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration = Duration::from_secs(6 * 60); const EXTERNAL_AGENT_RUNNER_MCP_STATUS_IO_TIMEOUT: Duration = Duration::from_secs(6 * 60); -const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(6); +const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(30); const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25); #[cfg(target_os = "linux")] @@ -3957,6 +3957,11 @@ mod tests { ); } + #[test] + fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() { + assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30)); + } + #[test] fn endpoint_reuse_requires_current_protocol_and_executable_identity() { let current_fingerprint = "b".repeat(64); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index e2184c13b..4b43fa9c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -10,6 +10,9 @@ const SWARM_CHAT_HISTORY_LIMIT: usize = 50; const SWARM_CHAT_PLAN_STEP_LIMIT: usize = 8; const SWARM_TURN_REPORT_PREFIX: &str = "[turn.report] "; const SWARM_TURN_REPORT_SCHEMA_VERSION: &str = "game-creator-swarm-turn-report.v1"; +const SWARM_TURN_FAILED_ERROR: &str = "swarm-turn-failed"; +const SWARM_TURN_INCOMPLETE_ERROR: &str = "swarm-turn-incomplete"; +const SWARM_TURN_RECONCILIATION_ERROR: &str = "swarm-turn-needs-reconciliation"; #[derive(Debug, Eq, PartialEq)] enum SwarmChatInput { @@ -38,6 +41,7 @@ enum SwarmGoalCommand { #[derive(Debug, Eq, PartialEq)] struct SwarmGoalObservation { session_id: String, + run_id: String, previous_message_count: usize, } @@ -91,6 +95,14 @@ struct SwarmRejectedResponseStreamSnapshot { #[derive(Debug, Eq, PartialEq)] enum SwarmTurnOutcome { Settled(SwarmTurnReport), + Failed { + agent_ids: Vec, + report: SwarmTurnReport, + }, + Incomplete { + reasons: Vec, + report: SwarmTurnReport, + }, NeedsReconciliation { agent_ids: Vec, report: SwarmTurnReport, @@ -98,10 +110,24 @@ enum SwarmTurnOutcome { Quit, } +#[derive(Debug, Eq, PartialEq)] +struct SwarmTurnObservation { + outcome: SwarmTurnOutcome, + input_closed: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwarmChatFlow { + Continue, + Exit, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] #[serde(rename_all = "kebab-case")] enum SwarmTurnReportOutcome { Settled, + Failed, + Incomplete, NeedsReconciliation, } @@ -130,6 +156,28 @@ struct SwarmTurnConversationMetrics { final_reply_chars: usize, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmRecoveredAssistant { + run_id: String, + finalization_id: String, + message_id: String, + content: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmTurnConversationBaseline { + previous_message_count: usize, + parent_run_id: String, + recovered_assistant: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SwarmTurnConversationSnapshot { + metrics: SwarmTurnConversationMetrics, + final_reply: Option, + recovered_before_observation: bool, +} + enum SwarmInputEvent { Line(String), Eof, @@ -139,6 +187,7 @@ enum SwarmInputEvent { enum SwarmConfirmationResolution { None, Handled, + InputClosed, Quit, } @@ -146,12 +195,66 @@ enum SwarmPromptDecision { Approve, Reject, Deferred, + InputClosed, Quit, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwarmTurnTerminalClassification { + Settled, + Failed, + Incomplete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwarmSpecialistFailureDisposition { + Recoverable, + Failed, + Incomplete, +} + +#[derive(Default)] +struct SwarmTerminalFailureScan { + failed_agents: Vec, + incomplete_reasons: Vec, + reconciliation_agents: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwarmNewRunLaunch<'a> { + ProjectSupervisor { + source: &'static str, + run_profile: &'a str, + }, + ExplicitParentDebug, +} + +fn resolve_swarm_new_run_launch<'a>( + parent_agent_id: &str, + run_profile: &'a str, +) -> Result, String> { + if !matches!( + run_profile, + AGENT_RUNTIME_RUN_PROFILE_STANDARD | AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ) { + return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); + } + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + run_profile, + }); + } + if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { + return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); + } + Ok(SwarmNewRunLaunch::ExplicitParentDebug) +} + pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, parent_agent_id: &str, + run_profile: &str, ) -> Result<(), String> { let (input_tx, input_rx) = mpsc::channel(); std::thread::spawn(move || { @@ -181,12 +284,19 @@ pub(crate) fn run_game_creator_swarm_chat_at( }); let stdout = std::io::stdout(); let mut output = stdout.lock(); - run_game_creator_swarm_chat_with_input(root, parent_agent_id, &input_rx, &mut output) + run_game_creator_swarm_chat_with_input( + root, + parent_agent_id, + run_profile, + &input_rx, + &mut output, + ) } fn run_game_creator_swarm_chat_with_input( root: &Path, parent_agent_id: &str, + run_profile: &str, input: &Receiver, output: &mut W, ) -> Result<(), String> { @@ -194,6 +304,7 @@ fn run_game_creator_swarm_chat_with_input( if parent_agent_id.is_empty() { return Err("parentAgentId 不能为空".to_string()); } + let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; let project_path = root.display().to_string(); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; @@ -223,12 +334,23 @@ fn run_game_creator_swarm_chat_with_input( .session_id .as_deref() .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let parent_run_id = swarm_parent_runtime(parent_agent_id, session_id, &existing_runtimes) + .map(|runtime| runtime.state.run_id.as_str()) + .unwrap_or_default(); + let mut conversation_baseline = + new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id); + capture_recovered_swarm_assistant_at( + root, + parent_agent_id, + session_id, + &mut conversation_baseline, + )?; let mut observer = SwarmRuntimeObserver::default(); let outcome = wait_for_swarm_turn( root, parent_agent_id, session_id, - before.messages.len(), + conversation_baseline, input, output, &mut observer, @@ -270,11 +392,15 @@ fn run_game_creator_swarm_chat_with_input( else { continue; }; + let conversation_baseline = new_swarm_turn_conversation_baseline( + observation.previous_message_count, + &observation.run_id, + ); let outcome = wait_for_swarm_turn( root, parent_agent_id, &observation.session_id, - observation.previous_message_count, + conversation_baseline, input, output, &mut observer, @@ -323,11 +449,15 @@ fn run_game_creator_swarm_chat_with_input( goal.run_id, steer_id, result.provider_interrupted ) .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = new_swarm_turn_conversation_baseline( + before.messages.len(), + &goal.run_id, + ); let outcome = wait_for_swarm_turn( root, parent_agent_id, &session_id, - before.messages.len(), + conversation_baseline, input, output, &mut observer, @@ -369,24 +499,43 @@ fn run_game_creator_swarm_chat_with_input( } } let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); - let started = start_game_creator_agent_runtime_task( - project_path.clone(), - parent_agent_id.to_string(), - Some(session_id.clone()), - message, - requested_run_id.clone(), - )?; + let started = match new_run_launch { + SwarmNewRunLaunch::ProjectSupervisor { + source, + run_profile, + } => start_game_creator_supervisor_background_task_for_session_at( + root, + Some(&session_id), + &message, + &requested_run_id, + source, + run_profile, + )?, + SwarmNewRunLaunch::ExplicitParentDebug => { + start_game_creator_agent_runtime_task( + project_path.clone(), + parent_agent_id.to_string(), + Some(session_id.clone()), + message, + requested_run_id.clone(), + )? + } + }; writeln!( output, "[已投递] agent={} session={} run={}", parent_agent_id, started.state.session_id, requested_run_id ) .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = new_swarm_turn_conversation_baseline( + before.messages.len(), + &started.state.run_id, + ); let outcome = wait_for_swarm_turn( root, parent_agent_id, &session_id, - before.messages.len(), + conversation_baseline, input, output, &mut observer, @@ -423,7 +572,7 @@ fn prompt_swarm_decision( .map_err(|error| format!("刷新终端失败:{error}"))?; loop { let Some(line) = receive_swarm_chat_line(input)? else { - return Err("确认输入已结束;待确认动作保持未处理".to_string()); + return Ok(SwarmPromptDecision::InputClosed); }; match line.to_ascii_lowercase().as_str() { "approve" | "yes" | "y" | "批准" => return Ok(SwarmPromptDecision::Approve), @@ -696,6 +845,7 @@ fn execute_swarm_goal_command( print_swarm_goal_mutation("已启动", &result, output)?; Ok(Some(SwarmGoalObservation { session_id, + run_id: result.goal.run_id.clone(), previous_message_count, })) } @@ -715,6 +865,7 @@ fn execute_swarm_goal_command( Ok( (result.goal.status == AGENT_GOAL_STATUS_ACTIVE).then_some(SwarmGoalObservation { session_id, + run_id: result.goal.run_id.clone(), previous_message_count, }), ) @@ -743,6 +894,7 @@ fn execute_swarm_goal_command( print_swarm_goal_mutation("已恢复", &result, output)?; Ok(Some(SwarmGoalObservation { session_id, + run_id: result.goal.run_id.clone(), previous_message_count, })) } @@ -928,7 +1080,7 @@ fn wait_for_swarm_turn( root: &Path, parent_agent_id: &str, session_id: &str, - previous_message_count: usize, + conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, observer: &mut SwarmRuntimeObserver, @@ -938,6 +1090,7 @@ fn wait_for_swarm_turn( let mut stable_since: Option = None; let mut recovery_scan_required = true; let mut last_runner_check = Instant::now(); + let mut input_closed = false; loop { let runtimes = read_game_creator_agent_runtimes_at(root)?; let changed = observer.print_changes(&runtimes, output)?; @@ -951,34 +1104,94 @@ fn wait_for_swarm_turn( root, parent_agent_id, session_id, - previous_message_count, + &conversation_baseline, &runtimes, reconciliation, ); } - match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { - SwarmConfirmationResolution::Handled => { - stable_since = None; - recovery_scan_required = true; - continue; + if !input_closed { + match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { + SwarmConfirmationResolution::Handled => { + stable_since = None; + recovery_scan_required = true; + continue; + } + SwarmConfirmationResolution::InputClosed => { + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; + stable_since = None; + continue; + } + SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmConfirmationResolution::None => {} + } + match observer.resolve_user_input_requests( + root, + parent_agent_id, + &runtimes, + input, + output, + )? { + SwarmConfirmationResolution::Handled => { + stable_since = None; + recovery_scan_required = true; + continue; + } + SwarmConfirmationResolution::InputClosed => { + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; + stable_since = None; + continue; + } + SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmConfirmationResolution::None => {} } - SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), - SwarmConfirmationResolution::None => {} } - match observer.resolve_user_input_requests( - root, - parent_agent_id, - &runtimes, - input, - output, - )? { - SwarmConfirmationResolution::Handled => { - stable_since = None; - recovery_scan_required = true; - continue; - } - SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), - SwarmConfirmationResolution::None => {} + let pending_interactions = + swarm_unhandled_interaction_reasons(parent_agent_id, &runtimes, input_closed); + if !pending_interactions.is_empty() { + observer.close_response_line(output)?; + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + pending_interactions, + ); + } + let failure_scan = + scan_swarm_terminal_failures_at(root, parent_agent_id, session_id, &runtimes); + if !failure_scan.reconciliation_agents.is_empty() { + observer.close_response_line(output)?; + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.reconciliation_agents, + ); + } + if !failure_scan.failed_agents.is_empty() { + observer.close_response_line(output)?; + return build_failed_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.failed_agents, + ); + } + if !failure_scan.incomplete_reasons.is_empty() { + observer.close_response_line(output)?; + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.incomplete_reasons, + ); } if last_runner_check.elapsed() >= Duration::from_secs(2) { let runner = read_external_agent_runner_status(); @@ -990,7 +1203,7 @@ fn wait_for_swarm_turn( root, parent_agent_id, session_id, - previous_message_count, + &conversation_baseline, &runtimes, reconciliation, ); @@ -1017,25 +1230,91 @@ fn wait_for_swarm_turn( stable_since = Some(Instant::now()); continue; } - let conversation_metrics = print_new_parent_reply( + let conversation_metrics = read_turn_conversation_metrics( root, parent_agent_id, session_id, - previous_message_count, - output, - observer, + &conversation_baseline, )?; - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Settled, - parent_agent_id, - session_id, - &runtimes, + let parent_runtime = swarm_parent_runtime(parent_agent_id, session_id, &runtimes); + let completion_blockers = parent_runtime + .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) + .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]); + match classify_swarm_turn_terminal( + parent_runtime, conversation_metrics, 0, - ); - return Ok(SwarmTurnOutcome::Settled(report)); + 0, + completion_blockers.len(), + ) { + SwarmTurnTerminalClassification::Settled => { + let printed_metrics = print_new_parent_reply( + root, + parent_agent_id, + session_id, + &conversation_baseline, + output, + observer, + )?; + if printed_metrics != conversation_metrics { + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + vec!["conversation-changed-before-settle".to_string()], + ); + } + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + parent_agent_id, + session_id, + &runtimes, + conversation_metrics, + 0, + ); + return Ok(SwarmTurnOutcome::Settled(report)); + } + SwarmTurnTerminalClassification::Failed => { + return build_failed_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + vec![format!( + "{}:{}", + parent_agent_id, + parent_runtime + .map(|runtime| runtime.state.phase.as_str()) + .unwrap_or("missing") + )], + ); + } + SwarmTurnTerminalClassification::Incomplete => { + let mut reasons = completion_blockers; + append_swarm_terminal_snapshot_reasons( + &mut reasons, + parent_runtime, + conversation_metrics, + ); + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + reasons, + ); + } + } } } + if input_closed { + std::thread::sleep(poll_interval); + continue; + } match input.recv_timeout(poll_interval) { Ok(SwarmInputEvent::Line(line)) => { let Some(command) = parse_swarm_chat_input(&line) else { @@ -1090,8 +1369,7 @@ fn wait_for_swarm_turn( } } Ok(SwarmInputEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { - observer.close_response_line(output)?; - return Ok(SwarmTurnOutcome::Quit); + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; } Ok(SwarmInputEvent::Error(error)) => { observer.close_response_line(output)?; @@ -1121,6 +1399,352 @@ fn runtime_is_busy(runtime: &AgentRuntimeResult) -> bool { || runtime.task_queue.waiting_for_user_input > 0 } +fn mark_swarm_turn_input_closed( + input_closed: &mut bool, + observer: &mut SwarmRuntimeObserver, + output: &mut W, +) -> Result<(), String> { + if *input_closed { + return Ok(()); + } + *input_closed = true; + observer.close_response_line(output)?; + writeln!(output, "[输入已关闭] 当前 turn 继续运行,等待可信终态。") + .map_err(|error| format!("写入终端失败:{error}")) +} + +fn swarm_parent_runtime<'a>( + parent_agent_id: &str, + session_id: &str, + runtimes: &'a [AgentRuntimeResult], +) -> Option<&'a AgentRuntimeResult> { + runtimes.iter().find(|runtime| { + runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + }) +} + +fn runtime_terminal_failure_kind(runtime: &AgentRuntimeResult) -> Option<&'static str> { + if runtime.state.phase == "needs-reconciliation" { + None + } else if runtime.state.phase == "budget-exhausted" { + Some("budget-exhausted") + } else if runtime.state.status == "cancelled" || runtime.state.phase == "cancelled" { + Some("cancelled") + } else if runtime.state.status == "failed" { + Some("failed") + } else { + None + } +} + +fn parent_runtime_is_active(runtime: &AgentRuntimeResult) -> bool { + runtime.state.phase != "needs-reconciliation" + && (matches!( + runtime.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || runtime.recent_tasks.iter().any(|task| { + task.run_id == runtime.state.run_id + && matches!( + task.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) + })) +} + +fn static_delegate_delivery_has_repairable_contract( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery.repair_of_delegation_id.is_none() + && delivery.status != StaticDelegateDeliveryStatus::Suppressed + && (!delivery.acceptance_criteria.is_empty() || !delivery.expected_artifacts.is_empty()) + && delivery.structured_result.as_ref().is_none_or(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsRepair + }) +} + +fn classify_failed_specialist( + parent: &AgentRuntimeResult, + child: &AgentRuntimeResult, + delivery: Option<&StaticDelegateDeliveryRecord>, + successful_repair: bool, +) -> SwarmSpecialistFailureDisposition { + let delivery_matches = delivery.is_some_and(|delivery| { + child.state.source == "agent-delegate" + && child.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) + && child.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) + && child.state.delegation_id.as_deref() == Some(delivery.delegation_id.as_str()) + && delivery.parent_agent_id == parent.state.agent_id + && delivery.parent_session_id == parent.state.session_id + && delivery.parent_run_id == parent.state.run_id + && delivery.target_agent_id == child.state.agent_id + && delivery.target_session_id == child.state.session_id + && delivery.target_run_id == child.state.run_id + }); + if !delivery_matches { + return SwarmSpecialistFailureDisposition::Failed; + } + let delivery = delivery.expect("matching delivery exists"); + if !static_delegate_delivery_has_repairable_contract(delivery) { + return SwarmSpecialistFailureDisposition::Failed; + } + if successful_repair { + return SwarmSpecialistFailureDisposition::Recoverable; + } + if parent_runtime_is_active(parent) { + SwarmSpecialistFailureDisposition::Recoverable + } else { + SwarmSpecialistFailureDisposition::Incomplete + } +} + +fn original_delivery_has_successful_repair( + original: &StaticDelegateDeliveryRecord, + claimed_deliveries: &[StaticDelegateDeliveryRecord], +) -> bool { + original.repair_of_delegation_id.is_none() + && claimed_deliveries.iter().any(|candidate| { + candidate.repair_of_delegation_id.as_deref() == Some(original.delegation_id.as_str()) + && candidate.terminal_status.as_deref() == Some("completed") + && candidate.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::EvidenceReady + }) + }) +} + +fn scan_swarm_terminal_failures_at( + root: &Path, + parent_agent_id: &str, + session_id: &str, + runtimes: &[AgentRuntimeResult], +) -> SwarmTerminalFailureScan { + let mut scan = SwarmTerminalFailureScan::default(); + let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, runtimes) else { + return scan; + }; + let claimed_deliveries = match claimed_static_delegate_deliveries_at( + root, + &parent.state.agent_id, + &parent.state.run_id, + ) { + Ok(deliveries) => deliveries, + Err(_) => { + scan.reconciliation_agents + .push(parent.state.agent_id.clone()); + return scan; + } + }; + if let Some(kind) = runtime_terminal_failure_kind(parent) { + scan.failed_agents + .push(format!("{}:{kind}", parent.state.agent_id)); + } + for child in runtimes.iter().filter(|runtime| { + runtime.state.source == "agent-delegate" + && runtime.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) + && runtime.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) + && runtime_terminal_failure_kind(runtime).is_some() + }) { + let Some(delegation_id) = child + .state + .delegation_id + .as_deref() + .filter(|value| !value.is_empty()) + else { + scan.failed_agents.push(format!( + "{}:{}", + child.state.agent_id, + runtime_terminal_failure_kind(child).unwrap_or("failed") + )); + continue; + }; + let delivery = match read_static_delegate_delivery_at(root, delegation_id) { + Ok(Some(delivery)) => delivery, + Ok(None) | Err(_) => { + scan.reconciliation_agents + .push(child.state.agent_id.clone()); + continue; + } + }; + let successful_repair = + original_delivery_has_successful_repair(&delivery, &claimed_deliveries); + match classify_failed_specialist(parent, child, Some(&delivery), successful_repair) { + SwarmSpecialistFailureDisposition::Recoverable => {} + SwarmSpecialistFailureDisposition::Failed => scan.failed_agents.push(format!( + "{}:{}", + child.state.agent_id, + runtime_terminal_failure_kind(child).unwrap_or("failed") + )), + SwarmSpecialistFailureDisposition::Incomplete => scan + .incomplete_reasons + .push(format!("repair-required:{}", child.state.agent_id)), + } + } + scan.failed_agents.sort(); + scan.failed_agents.dedup(); + scan.incomplete_reasons.sort(); + scan.incomplete_reasons.dedup(); + scan.reconciliation_agents.sort(); + scan.reconciliation_agents.dedup(); + scan +} + +fn swarm_unhandled_interaction_reasons( + parent_agent_id: &str, + runtimes: &[AgentRuntimeResult], + input_closed: bool, +) -> Vec { + let mut reasons = Vec::new(); + for runtime in runtimes { + let waiting_for_confirmation = runtime.state.status == "waiting-for-confirmation" + || runtime.state.pending_tool_action.is_some() + || runtime.task_queue.waiting_for_confirmation > 0; + let waiting_for_user_input = runtime.state.status == "waiting-for-user-input" + || runtime.user_input_request.is_some() + || runtime.task_queue.waiting_for_user_input > 0; + if input_closed && waiting_for_confirmation { + reasons.push(format!("pending-confirmation:{}", runtime.state.agent_id)); + } + if waiting_for_user_input && (input_closed || runtime.state.agent_id != parent_agent_id) { + reasons.push(format!("pending-user-input:{}", runtime.state.agent_id)); + } + } + reasons.sort(); + reasons.dedup(); + reasons +} + +fn parent_runtime_completed(runtime: &AgentRuntimeResult) -> bool { + runtime.state.phase == "completed" + && matches!(runtime.state.status.as_str(), "idle" | "completed") +} + +fn classify_swarm_turn_terminal( + parent: Option<&AgentRuntimeResult>, + conversation_metrics: SwarmTurnConversationMetrics, + failed_runtime_count: usize, + pending_interaction_count: usize, + completion_blocker_count: usize, +) -> SwarmTurnTerminalClassification { + if failed_runtime_count > 0 + || parent.is_some_and(|runtime| runtime_terminal_failure_kind(runtime).is_some()) + { + return SwarmTurnTerminalClassification::Failed; + } + if parent.is_none_or(|runtime| !parent_runtime_completed(runtime)) + || pending_interaction_count > 0 + || completion_blocker_count > 0 + || conversation_metrics.new_assistant_message_count != 1 + || conversation_metrics.final_reply_chars == 0 + { + return SwarmTurnTerminalClassification::Incomplete; + } + SwarmTurnTerminalClassification::Settled +} + +fn append_swarm_terminal_snapshot_reasons( + reasons: &mut Vec, + parent: Option<&AgentRuntimeResult>, + conversation_metrics: SwarmTurnConversationMetrics, +) { + match parent { + None => reasons.push("parent-runtime-missing".to_string()), + Some(parent) if !parent_runtime_completed(parent) => reasons.push(format!( + "parent-not-completed:{}:{}", + parent.state.status, parent.state.phase + )), + Some(_) => {} + } + if conversation_metrics.new_assistant_message_count != 1 { + reasons.push(format!( + "assistant-count={}", + conversation_metrics.new_assistant_message_count + )); + } else if conversation_metrics.final_reply_chars == 0 { + reasons.push("assistant-empty".to_string()); + } + reasons.sort(); + reasons.dedup(); +} + +fn swarm_parent_completion_contract_blockers_at( + root: &Path, + parent: &AgentRuntimeResult, +) -> Vec { + let mut blockers = Vec::new(); + let agent_id = parent.state.agent_id.as_str(); + let run_id = parent.state.run_id.as_str(); + if let Some(blocker) = structured_plan_completion_blocker(&parent.state) { + blockers.push(blocker.tool); + } + if parent.state.goal_id.is_some() + && !matches!( + parent.state.goal_status.as_deref(), + Some(AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED) + ) + { + blockers.push("runtime.goal".to_string()); + } + if parent.state.pending_tool_action.is_some() { + blockers.push("runtime.pending_tool_action".to_string()); + } + match super::provider_retry::read_for_run_at(root, agent_id, run_id) { + Ok(None) => {} + Ok(Some(_)) | Err(_) => blockers.push("runtime.provider_retry".to_string()), + } + let provider_action_batch_path = + game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); + if provider_action_batch_path.exists() + || agent_runtime_json_sidecar_backup_path(&provider_action_batch_path).exists() + { + blockers.push("runtime.provider_action_batch".to_string()); + } + let finalization_path = game_creator_agent_runtime_finalization_path(root, agent_id, run_id); + if finalization_path.exists() + || agent_runtime_json_sidecar_backup_path(&finalization_path).exists() + { + blockers.push("runtime.finalization".to_string()); + } + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { + Ok(resolution) => { + match read_supervisor_collaboration_state_at(root, agent_id, run_id) { + Ok(state) => { + if supervisor_collaboration_completion_gap(&resolution.policy, &state) + .is_some() + { + blockers.push("runtime.collaboration_policy".to_string()); + } + } + Err(_) => blockers.push("runtime.collaboration_policy".to_string()), + } + } + Err(_) => blockers.push("runtime.collaboration_policy".to_string()), + } + } + if let Some(blocker) = process_session_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = isolated_join_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = static_delegate_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = project_verification_completion_blocker_at(root, agent_id, run_id, &[]) { + blockers.push(blocker.tool); + } + blockers.sort(); + blockers.dedup(); + blockers +} + fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { runtimes .iter() @@ -1139,12 +1763,14 @@ fn build_reconciliation_turn_outcome( root: &Path, parent_agent_id: &str, session_id: &str, - previous_message_count: usize, + conversation_baseline: &SwarmTurnConversationBaseline, runtimes: &[AgentRuntimeResult], - agent_ids: Vec, + mut agent_ids: Vec, ) -> Result { + agent_ids.sort(); + agent_ids.dedup(); let conversation_metrics = - read_turn_conversation_metrics(root, parent_agent_id, session_id, previous_message_count)?; + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; let report = build_swarm_turn_report( SwarmTurnReportOutcome::NeedsReconciliation, parent_agent_id, @@ -1156,22 +1782,160 @@ fn build_reconciliation_turn_outcome( Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) } +fn build_failed_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: &SwarmTurnConversationBaseline, + runtimes: &[AgentRuntimeResult], + mut agent_ids: Vec, +) -> Result { + agent_ids.sort(); + agent_ids.dedup(); + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Failed, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + 0, + ); + Ok(SwarmTurnOutcome::Failed { agent_ids, report }) +} + +fn build_incomplete_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: &SwarmTurnConversationBaseline, + runtimes: &[AgentRuntimeResult], + mut reasons: Vec, +) -> Result { + reasons.sort(); + reasons.dedup(); + if reasons.is_empty() { + reasons.push("terminal-contract-not-proven".to_string()); + } + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Incomplete, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + 0, + ); + Ok(SwarmTurnOutcome::Incomplete { reasons, report }) +} + +fn new_swarm_turn_conversation_baseline( + previous_message_count: usize, + parent_run_id: impl Into, +) -> SwarmTurnConversationBaseline { + SwarmTurnConversationBaseline { + previous_message_count, + parent_run_id: parent_run_id.into(), + recovered_assistant: None, + } +} + +fn capture_recovered_swarm_assistant_at( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &mut SwarmTurnConversationBaseline, +) -> Result<(), String> { + if baseline.parent_run_id.trim().is_empty() || baseline.recovered_assistant.is_some() { + return Ok(()); + } + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + if baseline.previous_message_count > conversation.messages.len() { + return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); + } + if baseline.previous_message_count < conversation.messages.len() { + return Ok(()); + } + let Some(journal) = read_game_creator_agent_runtime_finalization_journal( + root, + parent_agent_id, + &baseline.parent_run_id, + )? + else { + return Ok(()); + }; + if journal.agent_id != parent_agent_id + || journal.session_id != session_id + || journal.run_id != baseline.parent_run_id + { + return Err("Swarm 恢复 finalization 与目标 parent Session/run 不匹配".to_string()); + } + if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Ok(()); + } + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: journal.run_id, + finalization_id: journal.finalization_id, + message_id: journal.message_id, + content: journal.response, + }); + Ok(()) +} + +fn read_turn_conversation_snapshot( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &SwarmTurnConversationBaseline, +) -> Result { + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + if baseline.previous_message_count > conversation.messages.len() { + return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); + } + let (mut metrics, final_reply) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(baseline.previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); + let mut final_reply = final_reply.map(str::to_string); + let recovered_before_observation = baseline.recovered_assistant.is_some(); + if let Some(recovered) = baseline.recovered_assistant.as_ref() { + if recovered.run_id != baseline.parent_run_id + || recovered.finalization_id.trim().is_empty() + || recovered.message_id.trim().is_empty() + { + return Err("Swarm 恢复 assistant 身份不完整".to_string()); + } + if metrics.new_assistant_message_count != 0 { + return Err("Swarm 恢复 assistant 与 baseline 后的新回复重叠".to_string()); + } + metrics.new_assistant_message_count = metrics.new_assistant_message_count.saturating_add(1); + if final_reply.is_none() { + metrics.final_reply_chars = recovered.content.chars().count(); + final_reply = Some(recovered.content.clone()); + } + } + Ok(SwarmTurnConversationSnapshot { + metrics, + final_reply, + recovered_before_observation, + }) +} + fn read_turn_conversation_metrics( root: &Path, parent_agent_id: &str, session_id: &str, - previous_message_count: usize, + baseline: &SwarmTurnConversationBaseline, ) -> Result { - let conversation = - read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - let (metrics, _) = summarize_new_assistant_messages( - conversation - .messages - .iter() - .skip(previous_message_count) - .map(|message| (message.role.as_str(), message.content.as_str())), - ); - Ok(metrics) + read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline) + .map(|snapshot| snapshot.metrics) } fn summarize_new_assistant_messages<'a>( @@ -1247,22 +2011,25 @@ fn print_new_parent_reply( root: &Path, parent_agent_id: &str, session_id: &str, - previous_message_count: usize, + baseline: &SwarmTurnConversationBaseline, output: &mut W, observer: &mut SwarmRuntimeObserver, ) -> Result { - let conversation = - read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - let (metrics, reply) = summarize_new_assistant_messages( - conversation - .messages - .iter() - .skip(previous_message_count) - .map(|message| (message.role.as_str(), message.content.as_str())), - ); + let snapshot = read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline)?; observer.close_response_line(output)?; - print_settled_parent_reply(parent_agent_id, session_id, reply, observer, output)?; - Ok(metrics) + if snapshot.recovered_before_observation { + writeln!(output, "[本轮结束] 父 Agent 回复已在恢复前持久化。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + print_settled_parent_reply( + parent_agent_id, + session_id, + snapshot.final_reply.as_deref(), + observer, + output, + )?; + } + Ok(snapshot.metrics) } fn print_settled_parent_reply( @@ -1287,6 +2054,24 @@ fn print_settled_parent_reply( fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { match outcome { SwarmTurnOutcome::Settled(report) => print_swarm_turn_report(&report, output), + SwarmTurnOutcome::Failed { agent_ids, report } => { + writeln!( + output, + "[已失败] 以下 Runtime 到达失败终态:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::Incomplete { reasons, report } => { + writeln!( + output, + "[未完成] 当前 turn 未满足可信终态:{}", + reasons.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } SwarmTurnOutcome::NeedsReconciliation { agent_ids, report } => { writeln!( output, @@ -1734,6 +2519,9 @@ impl SwarmRuntimeObserver { SwarmPromptDecision::Approve => true, SwarmPromptDecision::Reject => false, SwarmPromptDecision::Deferred => return Ok(SwarmConfirmationResolution::Handled), + SwarmPromptDecision::InputClosed => { + return Ok(SwarmConfirmationResolution::InputClosed) + } SwarmPromptDecision::Quit => return Ok(SwarmConfirmationResolution::Quit), }; let project_path = root.display().to_string(); @@ -1820,7 +2608,7 @@ impl SwarmRuntimeObserver { .flush() .map_err(|error| format!("刷新终端失败:{error}"))?; let Some(line) = receive_swarm_chat_line(input)? else { - return Err("用户输入已结束;Needs input 请求保持未回答".to_string()); + return Ok(SwarmConfirmationResolution::InputClosed); }; if matches!(line.as_str(), "/quit" | "/exit") { return Ok(SwarmConfirmationResolution::Quit); @@ -2152,6 +2940,99 @@ mod tests { snapshot } + #[test] + fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { + for run_profile in [ + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ] { + assert_eq!( + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_profile, + ) + .expect("resolve supervisor launch"), + SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + run_profile, + } + ); + } + } + + #[test] + fn explicit_parent_debug_keeps_standard_profile_only() { + assert_eq!( + resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) + .expect("resolve explicit parent debug launch"), + SwarmNewRunLaunch::ExplicitParentDebug, + ); + assert!(resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect_err("autonomous profile must stay on the supervisor root run") + .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "unsupported", + ) + .is_err()); + } + + #[test] + fn same_run_steer_preserves_bound_autonomous_profile() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-profile-steer-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-profile", "Swarm Profile Steer") + .expect("initialize profile steer project"); + let run_id = "swarm-profile-steer-run"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "生成一版可试玩项目", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "准备自主构建", + vec!["实现并验证最小可玩闭环".to_string()], + ) + .expect("start autonomous supervisor runtime"); + + let steered = steer_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "swarm-profile-steer-1", + "保持当前目标并补充触屏操作", + "swarm-cli", + ) + .expect("steer autonomous supervisor runtime"); + + assert_eq!(steered.runtime.state.run_id, run_id); + assert_eq!( + steered.runtime.state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!( + steered.runtime.state.run_profile_binding_fingerprint, + binding.binding_fingerprint + ); + fs::remove_dir_all(root).ok(); + } + #[test] fn parses_chat_commands_without_stealing_normal_messages() { assert_eq!(parse_swarm_chat_input(" "), None); @@ -2346,6 +3227,505 @@ mod tests { assert!(!runtimes_are_busy(&[runtime("failed", "failed", 0)])); } + #[test] + fn active_turn_eof_closes_input_once_without_requesting_quit() { + let mut input_closed = false; + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + + mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) + .expect("close active turn input"); + mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) + .expect("repeat closed input is idempotent"); + + assert!(input_closed); + let output = String::from_utf8(output).expect("input close output is utf-8"); + assert_eq!(output.matches("[输入已关闭]").count(), 1); + assert!(output.contains("继续运行,等待可信终态")); + assert!(!output.contains("已退出 Agent Swarm Chat")); + } + + #[test] + fn active_turn_eof_keeps_observing_until_parent_completes() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-eof-active-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-eof", "Swarm EOF active turn") + .expect("initialize EOF project"); + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + let idle = default_game_creator_agent_runtime_state(role.task_id, "run-eof-idle"); + write_game_creator_agent_runtime_state(&root, &idle) + .expect("persist valid idle specialist state"); + } + } + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let before = append_local_conversation_message_at( + &root, + Some(parent_agent_id), + LocalConversationMessage { + role: "user".to_string(), + content: "继续完成当前项目".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append turn user message"); + let session_id = before.session_id.clone().expect("active parent session"); + let mut parent = runtime("running", "planning", 0).state; + parent.agent_id = parent_agent_id.to_string(); + parent.task_id = parent_agent_id.to_string(); + parent.session_id = session_id.clone(); + parent.run_id = "run-eof-active".to_string(); + parent.source = "agent-background-task".to_string(); + parent.current_task = "继续完成当前项目".to_string(); + write_game_creator_agent_runtime_state(&root, &parent).expect("persist active parent"); + let conversation_baseline = + new_swarm_turn_conversation_baseline(before.messages.len(), &parent.run_id); + + let completion_root = root.clone(); + let completion_session_id = session_id.clone(); + let completion = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(15)); + append_local_conversation_message_for_session_at( + &completion_root, + Some(parent_agent_id), + Some(&completion_session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "已完成可信终态".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append terminal assistant message"); + parent.status = "idle".to_string(); + parent.phase = "completed".to_string(); + parent.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(&completion_root, &parent) + .expect("persist completed parent"); + }); + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Eof).expect("send active turn EOF"); + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + + let outcome = wait_for_swarm_turn( + &root, + parent_agent_id, + &session_id, + conversation_baseline, + &rx, + &mut output, + &mut observer, + Duration::from_millis(2), + Duration::from_millis(8), + ) + .expect("observe active turn after EOF"); + completion.join().expect("join completion writer"); + + let output = String::from_utf8(output).expect("EOF turn output is utf-8"); + let runtime_diagnostics = read_game_creator_agent_runtimes_at(&root) + .expect("read terminal runtime diagnostics") + .into_iter() + .filter(|runtime| runtime.state.phase == "needs-reconciliation") + .map(|runtime| { + format!( + "{}:{}", + runtime.state.agent_id, + runtime.state.error.unwrap_or_default() + ) + }) + .collect::>(); + assert!( + matches!(outcome, SwarmTurnOutcome::Settled(_)), + "unexpected outcome: {outcome:?}; diagnostics={runtime_diagnostics:?}; output={output}" + ); + assert!(output.contains("[输入已关闭]")); + assert!(output.contains("已完成可信终态")); + assert!(!output.contains("已退出 Agent Swarm Chat")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn recovered_prebaseline_assistant_counts_once_without_duplicate_output() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-recovered-assistant-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at( + &root, + "project-swarm-recovered-assistant", + "Swarm recovered assistant", + ) + .expect("initialize recovered assistant project"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let conversation = append_local_conversation_message_at( + &root, + Some(parent_agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "已在恢复阶段持久化".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append recovered assistant"); + let session_id = conversation.session_id.expect("active parent session"); + let mut baseline = + new_swarm_turn_conversation_baseline(conversation.messages.len(), "run-recovered"); + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: "run-recovered".to_string(), + finalization_id: "finalization-recovered".to_string(), + message_id: "message-recovered".to_string(), + content: "已在恢复阶段持久化".to_string(), + }); + + let snapshot = + read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) + .expect("read recovered conversation snapshot"); + assert_eq!(snapshot.metrics.new_assistant_message_count, 1); + assert_eq!( + snapshot.metrics.final_reply_chars, + "已在恢复阶段持久化".chars().count() + ); + assert_eq!(snapshot.final_reply.as_deref(), Some("已在恢复阶段持久化")); + assert!(snapshot.recovered_before_observation); + + let mut output = Vec::new(); + let mut observer = SwarmRuntimeObserver::default(); + let metrics = print_new_parent_reply( + &root, + parent_agent_id, + &session_id, + &baseline, + &mut output, + &mut observer, + ) + .expect("print recovered parent reply"); + assert_eq!(metrics, snapshot.metrics); + let output = String::from_utf8(output).expect("recovered output is utf-8"); + assert!(output.contains("父 Agent 回复已在恢复前持久化")); + assert!(!output.contains("已在恢复阶段持久化")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn recovered_assistant_cannot_overlap_a_new_terminal_reply() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-recovered-overlap-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at( + &root, + "project-swarm-recovered-overlap", + "Swarm recovered overlap", + ) + .expect("initialize recovered overlap project"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let before = + read_local_conversation_for_session_at(root.as_path(), Some(parent_agent_id), None) + .expect("read initial conversation"); + let session_id = before.session_id.expect("active parent session"); + append_local_conversation_message_for_session_at( + &root, + Some(parent_agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "baseline 后的新回复".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append new terminal reply"); + let mut baseline = + new_swarm_turn_conversation_baseline(before.messages.len(), "run-overlap"); + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: "run-overlap".to_string(), + finalization_id: "finalization-overlap".to_string(), + message_id: "message-overlap".to_string(), + content: "恢复回复".to_string(), + }); + + let error = read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) + .expect_err("recovered and new assistant replies must not be double counted"); + assert!(error.contains("与 baseline 后的新回复重叠")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn confirmation_prompt_propagates_eof_as_closed_input() { + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Eof).expect("send eof"); + let mut output = Vec::new(); + + let decision = prompt_swarm_decision( + Path::new("."), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &rx, + &mut output, + "confirm> ", + ) + .expect("EOF is a turn input state, not an error"); + + assert!(matches!(decision, SwarmPromptDecision::InputClosed)); + } + + #[test] + fn terminal_classifier_requires_completed_parent_unique_reply_and_clear_contract() { + let mut parent = runtime("idle", "completed", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-terminal".to_string(); + parent.state.run_id = "run-terminal".to_string(); + let unique_reply = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 12, + }; + + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 0), + SwarmTurnTerminalClassification::Settled + ); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 1, 0), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 1), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal( + Some(&parent), + SwarmTurnConversationMetrics::default(), + 0, + 0, + 0, + ), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal( + Some(&parent), + SwarmTurnConversationMetrics { + new_assistant_message_count: 2, + final_reply_chars: 12, + }, + 0, + 0, + 0, + ), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal(None, unique_reply, 0, 0, 0), + SwarmTurnTerminalClassification::Incomplete + ); + } + + #[test] + fn terminal_classifier_fails_parent_failure_cancel_and_budget_exhaustion() { + let metrics = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 8, + }; + for (status, phase) in [ + ("failed", "failed"), + ("cancelled", "cancelled"), + ("failed", "budget-exhausted"), + ] { + let parent = runtime(status, phase, 0); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), metrics, 0, 0, 0), + SwarmTurnTerminalClassification::Failed, + "parent {status}/{phase} must fail closed" + ); + } + let completed = runtime("idle", "completed", 0); + assert_eq!( + classify_swarm_turn_terminal(Some(&completed), metrics, 1, 0, 0), + SwarmTurnTerminalClassification::Failed + ); + } + + #[test] + fn pending_interactions_never_form_a_settled_snapshot() { + let mut parent = runtime("waiting-for-user-input", "waiting-for-user-input", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + let mut child = runtime("waiting-for-user-input", "waiting-for-user-input", 0); + child.state.agent_id = "code-prototype".to_string(); + let mut confirmation = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); + confirmation.state.agent_id = "quality-review".to_string(); + + assert!(swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[parent.clone()], + false, + ) + .is_empty()); + let child_reasons = swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[child], + false, + ); + assert_eq!(child_reasons, vec!["pending-user-input:code-prototype"]); + let closed_reasons = swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[parent, confirmation], + true, + ); + assert!(closed_reasons + .iter() + .any(|reason| reason == "pending-user-input:project-supervisor")); + assert!(closed_reasons + .iter() + .any(|reason| reason == "pending-confirmation:quality-review")); + } + + #[test] + fn original_specialist_failure_is_recoverable_but_repair_failure_closes() { + let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-parent".to_string(); + parent.state.run_id = "run-parent".to_string(); + let mut child = runtime("failed", "failed", 0); + child.state.agent_id = "code-prototype".to_string(); + child.state.session_id = "session-child".to_string(); + child.state.run_id = "run-child".to_string(); + child.state.source = "agent-delegate".to_string(); + child.state.parent_agent_id = Some(parent.state.agent_id.clone()); + child.state.parent_run_id = Some(parent.state.run_id.clone()); + child.state.delegation_id = Some("delivery-original".to_string()); + let acceptance = vec!["交付可运行原型".to_string()]; + let original = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-original", + "delivery-original", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + None, + ); + + assert_eq!( + classify_failed_specialist(&parent, &child, Some(&original), false), + SwarmSpecialistFailureDisposition::Recoverable + ); + let mut completed_parent = parent.clone(); + completed_parent.state.status = "idle".to_string(); + completed_parent.state.phase = "completed".to_string(); + assert_eq!( + classify_failed_specialist(&completed_parent, &child, Some(&original), false), + SwarmSpecialistFailureDisposition::Incomplete + ); + + child.state.run_id = "run-repair".to_string(); + child.state.delegation_id = Some("delivery-repair".to_string()); + let repair = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-repair", + "delivery-repair", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + Some("delivery-original"), + ); + assert_eq!( + classify_failed_specialist(&parent, &child, Some(&repair), false), + SwarmSpecialistFailureDisposition::Failed + ); + } + + #[test] + fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-repair-scan-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-repair", "Swarm repair scan") + .expect("initialize repair scan project"); + let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-parent".to_string(); + parent.state.run_id = "run-parent".to_string(); + let mut child = runtime("failed", "failed", 0); + child.state.agent_id = "code-prototype".to_string(); + child.state.session_id = "session-child".to_string(); + child.state.run_id = "run-child".to_string(); + child.state.source = "agent-delegate".to_string(); + child.state.parent_agent_id = Some(parent.state.agent_id.clone()); + child.state.parent_run_id = Some(parent.state.run_id.clone()); + child.state.delegation_id = Some("delivery-original".to_string()); + let acceptance = vec!["交付可运行原型".to_string()]; + let original = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-original", + "delivery-original", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("persist original delivery"); + + let original_scan = scan_swarm_terminal_failures_at( + &root, + &parent.state.agent_id, + &parent.state.session_id, + &[parent.clone(), child.clone()], + ); + assert!(original_scan.failed_agents.is_empty()); + assert!(original_scan.incomplete_reasons.is_empty()); + assert!(original_scan.reconciliation_agents.is_empty()); + + child.state.run_id = "run-repair".to_string(); + child.state.delegation_id = Some("delivery-repair".to_string()); + let repair = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-repair", + "delivery-repair", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + Some("delivery-original"), + ); + create_or_read_static_delegate_delivery_at(&root, &repair) + .expect("persist repair delivery"); + let repair_scan = scan_swarm_terminal_failures_at( + &root, + &parent.state.agent_id, + &parent.state.session_id, + &[parent.clone(), child], + ); + assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]); + assert!(repair_scan.incomplete_reasons.is_empty()); + assert!(repair_scan.reconciliation_agents.is_empty()); + + fs::remove_dir_all(root).ok(); + } + #[test] fn missing_confirmation_sidecar_is_reported_as_reconciliation() { let broken = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); @@ -2467,7 +3847,7 @@ mod tests { } #[test] - fn turn_outcome_prints_settled_and_reconciliation_reports_but_not_quit() { + fn turn_outcome_prints_all_terminal_reports_but_not_quit() { let metrics = SwarmTurnConversationMetrics { new_assistant_message_count: 1, final_reply_chars: 4, @@ -2491,6 +3871,49 @@ mod tests { assert!(settled_output.starts_with(SWARM_TURN_REPORT_PREFIX)); assert!(settled_output.contains("\"outcome\":\"settled\"")); + let failed_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Failed, + "project-supervisor", + "session-failed", + &[], + metrics, + 0, + ); + let mut failed_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Failed { + agent_ids: vec!["project-supervisor:budget-exhausted".to_string()], + report: failed_report, + }, + &mut failed_output, + ) + .expect("print failed report"); + let failed_output = String::from_utf8(failed_output).expect("failed output is utf-8"); + assert!(failed_output.starts_with("[已失败]")); + assert!(failed_output.contains("\"outcome\":\"failed\"")); + + let incomplete_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Incomplete, + "project-supervisor", + "session-incomplete", + &[], + metrics, + 0, + ); + let mut incomplete_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Incomplete { + reasons: vec!["assistant-count=0".to_string()], + report: incomplete_report, + }, + &mut incomplete_output, + ) + .expect("print incomplete report"); + let incomplete_output = + String::from_utf8(incomplete_output).expect("incomplete output is utf-8"); + assert!(incomplete_output.starts_with("[未完成]")); + assert!(incomplete_output.contains("\"outcome\":\"incomplete\"")); + let reconciliation_report = build_swarm_turn_report( SwarmTurnReportOutcome::NeedsReconciliation, "project-supervisor", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 43f01c432..2a7a69e98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1598,6 +1598,8 @@ fn pending_tool_action_for_test( session_id: state.session_id.clone(), run_id: state.run_id.clone(), source: state.source.clone(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), task: state.current_task.clone(), goal_id: state.goal_id.clone(), goal_revision: state.goal_revision, @@ -4056,6 +4058,56 @@ fn spawn_mock_llm_non_transient_provider_error( base_url } +fn spawn_mock_llm_upstream_400_then_raw_response( + response_body: serde_json::Value, + request_notice_sender: Option>, +) -> String { + let listener = bind_test_tcp_listener("mock autonomous upstream 400 retry bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut failed_stream, _) = listener + .accept() + .expect("mock autonomous upstream 400 accept"); + drop(read_mock_http_request(&mut failed_stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let failed_body = serde_json::json!({ + "error": { + "message": "mock transient autonomous invalid request", + "type": "invalid_request_error" + } + }) + .to_string(); + let failed_response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + failed_body.len(), + failed_body + ); + failed_stream + .write_all(failed_response.as_bytes()) + .expect("mock autonomous upstream 400 response"); + + let (mut recovered_stream, _) = listener + .accept() + .expect("mock autonomous upstream 400 recovery accept"); + drop(read_mock_http_request(&mut recovered_stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let recovered_body = response_body.to_string(); + let recovered_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + recovered_body.len(), + recovered_body + ); + recovered_stream + .write_all(recovered_response.as_bytes()) + .expect("mock autonomous upstream 400 recovery response"); + }); + base_url +} + fn spawn_mock_llm_tool_plan_then_transient_final_reply( planning_response: String, final_response: String, @@ -6010,6 +6062,1653 @@ fn agent_runtime_tool_policy_snapshot_reflects_agent_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn missing_runtime_state_stays_idle_without_a_run_profile_binding() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-empty-runtime", "空 Runtime 项目") + .expect("project init"); + + let runtime = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read default Supervisor runtime"); + + assert!(runtime.state.run_id.is_empty()); + assert_eq!(runtime.state.status, "idle"); + assert_eq!(runtime.state.phase, "idle"); + assert!(runtime.state.error.is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-autonomous-profile", "自主构建权限项目") + .expect("project init"); + let run_id = "autonomous-profile-root-run"; + let binding = 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 profile"); + let policy = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + ) + .expect("autonomous policy snapshot"); + + assert_eq!( + policy.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!( + policy.run_profile_binding_fingerprint, + binding.binding_fingerprint + ); + for tool in [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.verify", + "command.run_limited", + "preview.validate", + "agent.delegate", + "agent.spawn_isolated", + "agent.run_status", + ] { + assert!(policy.auto_tools.iter().any(|candidate| candidate == tool)); + assert!(!policy + .confirm_tools + .iter() + .any(|candidate| candidate == tool)); + } + assert!(policy + .denied_tools + .iter() + .any(|tool| tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL)); + for tool in [ + "project.git_commit", + "command.exec", + "command.start", + "command.stdin", + "command.terminate", + ] { + assert!(policy + .confirm_tools + .iter() + .any(|candidate| candidate == tool)); + } + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "file.write", + ) + .is_none()); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "project.git_commit", + ), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) + )); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.write".to_string()], + confirm_commands: ProjectPermissionPolicy::default().confirm_commands, + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny file write explicitly"); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "file.write", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + )); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_game_build_profile_is_immutable_and_inherited_by_child_runs() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-autonomous-inherit", "自主构建继承项目") + .expect("project init"); + let parent_run_id = "autonomous-inherit-parent"; + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind parent profile"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-inherit-delegation".to_string()), + }; + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "autonomous-inherit-child", + "agent-delegate", + None, + Some(&child_link), + ) + .expect("inherit child profile"); + assert_eq!(child.profile, parent.profile); + assert_eq!(child.root_agent_id, parent.agent_id); + assert_eq!(child.root_run_id, parent.run_id); + assert_eq!( + child.parent_binding_fingerprint.as_deref(), + Some(parent.binding_fingerprint.as_str()) + ); + assert!(bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "autonomous-inherit-child-conflict", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&child_link), + ) + .expect_err("child profile switch must fail") + .contains("不能切换")); + + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &root, + &child.agent_id, + &child.run_id, + )) + .expect("remove child binding"); + assert!(agent_runtime_run_profile_identity_at( + &root, + &child.agent_id, + &child.run_id, + Some(&child.profile), + Some(&child.binding_fingerprint), + ) + .expect_err("missing autonomous binding must fail closed") + .contains("拒绝降级")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-provider-retry", + "自主构建 Provider 重试项目", + ) + .expect("project init"); + + assert_eq!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + "design-director", + "legacy-standard-run", + 99, + ) + .expect("legacy standard retry policy"), + 3 + ); + let standard = bind_game_creator_agent_runtime_run_profile_at( + &root, + "design-director", + "standard-provider-retry-run", + "agent-chat", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind standard profile"); + assert_eq!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + &standard.agent_id, + &standard.run_id, + 0, + ) + .expect("standard zero retry policy"), + 0 + ); + assert_eq!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + &standard.agent_id, + &standard.run_id, + 99, + ) + .expect("standard capped retry policy"), + 3 + ); + + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "autonomous-provider-retry-parent", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + for (configured, expected) in [(0, 12), (14, 14), (99, 16)] { + assert_eq!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + &parent.agent_id, + &parent.run_id, + configured, + ) + .expect("autonomous parent retry policy"), + expected + ); + } + + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("autonomous-provider-retry-delegation".to_string()), + }; + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "autonomous-provider-retry-child", + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + let mut child_state = default_game_creator_agent_runtime_state(&child.agent_id, &child.run_id); + child_state.source = "agent-delegate".to_string(); + child_state.run_profile = child.profile.clone(); + child_state.run_profile_binding_fingerprint = child.binding_fingerprint.clone(); + child_state.parent_agent_id = child.parent_agent_id.clone(); + child_state.parent_run_id = child.parent_run_id.clone(); + child_state.delegation_id = child_link.delegation_id.clone(); + child_state.current_task = "继承自主构建 Provider 重试策略".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append autonomous child task projection"); + assert_eq!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + &child.agent_id, + &child.run_id, + 0, + ) + .expect("autonomous child retry policy"), + 12 + ); + + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &root, + &child.agent_id, + &child.run_id, + )) + .expect("remove autonomous child binding"); + assert!( + game_creator_agent_runtime_provider_transient_max_retries_at( + &root, + &child.agent_id, + &child.run_id, + 0, + ) + .expect_err("missing autonomous child binding must fail closed") + .contains("拒绝降级") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_game_build_tool_plan_guidance_bounds_each_source_payload() { + let guidance = AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_PAYLOAD_GUIDANCE; + for expected in [ + "最多提交一个", + "不得超过 8000 字符", + "合计不得超过 10000 字符", + "可运行且保留扩展点的紧凑 scaffold", + "后续 planning 轮次", + "闭合的 " + }), + )]); + validate_agent_runtime_autonomous_source_payload(&complete_game_index) + .expect("closed compact game index is allowed"); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_oversized_native_source_payload() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-source-repair", + "自主源码载荷修复项目", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("keep source payload fixture collaboration-neutral"); + let initial_game_index = + fs::read(root.join("game/index.html")).expect("read initial game index"); + let (sender, receiver) = mpsc::channel(); + let write_function = native_runtime_function_name("file.write").expect("write function"); + let oversized_arguments = serde_json::json!({ + "reason": "先写入完整大文件", + "input": { + "path": "game/index.html", + "content": "x".repeat(8_001) + } + }) + .to_string(); + let repaired_arguments = serde_json::json!({ + "response": "源码载荷已拆分到后续 planning 轮次。AUTONOMOUS_PAYLOAD_REPAIRED" + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-oversized-source", + &write_function, + oversized_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-source-repaired", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + repaired_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "autonomous-source-repair-key", + "baseUrl": {base_url:?}, + "model": "autonomous-source-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "autonomous-source-repair-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验证超限源码会自动修复", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "生成工具计划", + vec!["控制源码载荷".to_string()], + ) + .expect("start autonomous runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("repair oversized autonomous source payload") + .expect("repaired autonomous tool plan"); + assert!(plan.actions.is_empty()); + assert_eq!( + plan.response, + "源码载荷已拆分到后续 planning 轮次。AUTONOMOUS_PAYLOAD_REPAIRED" + ); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial autonomous request"); + assert!(initial_request.contains("不得超过 8000 字符")); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("autonomous source repair request"); + assert!(repair_request.contains("超过 8000 字符上限")); + assert!(repair_request.contains("后续 planning 轮次")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let records = read_agent_db_records_for_test(&root); + let repairs = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + assert_eq!(repairs[0]["maxAttempts"], 4); + let protocol = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .expect("repaired autonomous protocol audit"); + assert_eq!(protocol["repairAttempt"], 1); + assert_eq!(protocol["autonomousSourcePayloadValidated"], true); + assert_eq!(protocol["autonomousSourceMutationActionCount"], 0); + assert_eq!(protocol["autonomousSourceMaxFieldChars"], 0); + assert_eq!(protocol["autonomousSourceTotalChars"], 0); + assert_eq!( + fs::read(root.join("game/index.html")).expect("read unchanged game index"), + initial_game_index + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_pre_mutation_read_loop_into_action() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-pre-mutation-liveness", + "自主构建首次修改活性测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-pre-mutation-parent"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-pre-mutation-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-pre-mutation-delivery".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + + let (sender, receiver) = mpsc::channel(); + let read_arguments = serde_json::json!({"reason": "继续查看项目索引", "input": {}}).to_string(); + let write_arguments = serde_json::json!({ + "reason": "探索预算已用完,直接落地可运行 scaffold", + "input": { + "path": "game/index.html", + "content": "" + } + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-liveness-read", + &native_runtime_function_name("project.index").expect("index function"), + read_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-liveness-write", + &native_runtime_function_name("file.write").expect("write function"), + write_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-liveness-repair-key", + "baseUrl": {base_url:?}, + "model": "autonomous-liveness-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "实现可试玩游戏入口", + child_run_id, + "agent-delegate", + "读取后开始实现", + vec!["读取必要上下文".to_string(), "写入可运行入口".to_string()], + ) + .expect("start autonomous child runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + child_run_id, + &runtime.current_task, + &[], + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + ) + .await + .expect("repair pre-mutation read loop") + .expect("repaired autonomous action plan"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "file.write"); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial autonomous liveness request"); + assert!(initial_request.contains(&format!( + "首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮" + ))); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("autonomous liveness repair request"); + assert!(repair_request.contains(&format!( + "当前已到第 {} 轮", + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1 + ))); + assert!(repair_request.contains("不得继续只更新计划")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("restricted autonomous repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert!(repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(repair_function_names.contains( + native_runtime_function_name("file.write") + .expect("write function") + .as_str() + )); + assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(!repair_function_names.contains( + native_runtime_function_name("project.index") + .expect("index function") + .as_str() + )); + let write_function_name = + native_runtime_function_name("file.write").expect("write function name"); + let write_tool = repair_request_json["tools"] + .as_array() + .expect("restricted autonomous repair tools") + .iter() + .find(|tool| { + tool.get("name").and_then(serde_json::Value::as_str) + == Some(write_function_name.as_str()) + || tool + .get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + == Some(write_function_name.as_str()) + }) + .expect("write tool remains in repair catalog"); + let write_parameters = write_tool + .get("parameters") + .or_else(|| { + write_tool + .get("function") + .and_then(|function| function.get("parameters")) + }) + .expect("write tool parameters"); + assert_eq!( + write_parameters + .pointer("/properties/input/properties/content/maxLength") + .and_then(serde_json::Value::as_u64), + Some(6_000) + ); + assert_eq!( + repair_request_json["max_tokens"].as_u64(), + Some(AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS as u64) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let records = read_agent_db_records_for_test(&root); + let repairs = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == child_run_id + }) + .collect::>(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + assert_eq!(repairs[0]["maxAttempts"], 4); + let protocol = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" + && record["runId"] == child_run_id + }) + .expect("repaired autonomous liveness protocol audit"); + assert_eq!(protocol["repairAttempt"], 1); + assert_eq!(protocol["autonomousSourcePayloadValidated"], true); + assert_eq!(protocol["autonomousSourceMutationActionCount"], 1); + assert!(read_game_creator_agent_runtime_verification_gate( + &root, + "code-prototype", + child_run_id, + ) + .expect("read untouched verification gate") + .mutation_revision + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_final_response_with_incomplete_plan() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-response-plan-liveness", + "自主构建只读结论收束测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-response-plan-parent"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-response-plan-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-response-plan-delivery".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + "quality-review", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + let read_arguments = + serde_json::json!({"reason": "继续重复读取项目", "input": {}}).to_string(); + let incomplete_response = serde_json::json!({ + "response": "只读审查已经完成,当前入口缺少可玩状态合同。" + }) + .to_string(); + let completed_plan = serde_json::json!({ + "explanation": "只读审查和结论整理均已完成", + "steps": [ + {"step": "审查入口", "status": "completed"}, + {"step": "回传结论", "status": "completed"} + ] + }) + .to_string(); + let repaired_response = serde_json::json!({ + "response": "只读审查已经完成,当前入口缺少可玩状态合同。" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-response-plan-read", + &native_runtime_function_name("project.index").expect("index function"), + read_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-response-only", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + incomplete_response, + ), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-autonomous-plan-completed", + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + completed_plan, + ), + ( + "call-autonomous-response-repaired", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + repaired_response, + ), + ]), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "quality-review": {{ + "apiKey": "autonomous-response-plan-key", + "baseUrl": {base_url:?}, + "model": "autonomous-response-plan-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "quality-review", + "Perform an independent quality review and apply narrowly scoped fixes when needed; then report blockers.", + child_run_id, + "agent-delegate", + "开始只读审查", + vec!["审查入口".to_string(), "回传结论".to_string()], + ) + .expect("start autonomous review runtime"); + apply_agent_runtime_plan_update( + &mut runtime, + &AgentRuntimePlanUpdate { + explanation: "先审查入口,再回传结论".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "审查入口".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "回传结论".to_string(), + status: "in_progress".to_string(), + }, + ], + }, + ) + .expect("persist structured review plan"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write structured review runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "quality-review", + &runtime.session_id, + child_run_id, + &runtime.current_task, + &[], + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + ) + .await + .expect("repair autonomous response plan") + .expect("repaired response plan"); + assert_eq!(plan.response, "只读审查已经完成,当前入口缺少可玩状态合同。"); + assert!(plan.actions.is_empty()); + assert!(plan.plan_update.as_ref().is_some_and(|update| update + .steps + .iter() + .all(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED))); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial autonomous response request"); + let action_repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("autonomous forced-action repair request"); + assert!(action_repair_request.contains("首次项目修改前最多允许")); + let action_repair_request_json = mock_http_request_json(&action_repair_request); + let action_repair_function_names = action_repair_request_json["tools"] + .as_array() + .expect("forced-action repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert!(!action_repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(action_repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("autonomous response-plan repair request after forced-action repair"); + assert!(repair_request.contains("已给出结论但结构化计划仍未完成")); + assert!(repair_request.contains("必须在同一响应先调用 update_agent_plan")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("response-plan repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([ + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + ]) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_explicit_read_only_loop_into_completed_delivery() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-read-only-delivery", + "自主构建只读交付活性测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-read-only-parent"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-read-only-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-read-only-delivery".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + "quality-review", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + let read_arguments = + serde_json::json!({"reason": "核对当前入口", "input": {}}).to_string(); + let response = serde_json::json!({ + "response": "当前入口缺少可玩状态合同,需要程序 Agent 完成实现。" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-read-only-read", + &native_runtime_function_name("project.index").expect("index function"), + read_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-read-only-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "quality-review": {{ + "apiKey": "autonomous-read-only-key", + "baseUrl": {base_url:?}, + "model": "autonomous-read-only-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "quality-review", + "Repair the original review delivery now. Do not alter project files; inspect game/index.html and return exact findings and evidence.", + child_run_id, + "agent-delegate", + "开始只读审查", + vec!["审查入口".to_string(), "回传结论".to_string()], + ) + .expect("start autonomous read-only runtime"); + apply_agent_runtime_plan_update( + &mut runtime, + &AgentRuntimePlanUpdate { + explanation: "先审查入口,再回传结论".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "审查入口".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "回传结论".to_string(), + status: "in_progress".to_string(), + }, + ], + }, + ) + .expect("persist read-only plan"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write read-only runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "quality-review", + &runtime.session_id, + child_run_id, + &runtime.current_task, + &[], + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + ) + .await + .expect("repair explicit read-only liveness") + .expect("completed read-only delivery plan"); + assert!(plan.actions.is_empty()); + assert_eq!( + plan.response, + "当前入口缺少可玩状态合同,需要程序 Agent 完成实现。" + ); + assert!(plan.plan_update.is_none()); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial read-only request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("read-only delivery repair request"); + assert!(repair_request.contains("当前专业合同明确要求只读交付")); + assert!(repair_request.contains("不允许修改项目")); + assert!(repair_request.contains("Runtime 会在交付终态收束当前结构化计划")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("read-only repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME]) + ); + let completion_update = agent_runtime_read_only_delivery_completion_plan_update(&runtime) + .expect("runtime read-only completion update"); + apply_agent_runtime_plan_update(&mut runtime, &completion_update) + .expect("apply runtime read-only completion update"); + assert!(structured_plan_completion_blocker(&runtime).is_none()); + assert!(runtime + .plan_steps + .iter() + .all(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED)); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_post_mutation_read_loop_into_verification_action() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-post-mutation-liveness", + "自主构建修改后验证活性测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-post-mutation-parent"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-post-mutation-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-post-mutation-delivery".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + + let (sender, receiver) = mpsc::channel(); + let read_arguments = serde_json::json!({"reason": "继续重复读取项目", "input": {}}) + .to_string(); + let verify_arguments = serde_json::json!({ + "reason": "停止空转并立即重跑正式验证", + "input": { + "script": "test", + "expectedCommand": "node verify-e2e.mjs", + "timeoutSeconds": 120 + } + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-post-mutation-read", + &native_runtime_function_name("project.index").expect("index function"), + read_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-post-mutation-verify", + &native_runtime_function_name("project.verify").expect("verify function"), + verify_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-post-mutation-repair-key", + "baseUrl": {base_url:?}, + "model": "autonomous-post-mutation-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "修复失败验证并完成原型", + child_run_id, + "agent-delegate", + "根据验证诊断继续", + vec!["修复验证失败".to_string(), "重新执行验证".to_string()], + ) + .expect("start autonomous child runtime"); + let revision = prepare_agent_runtime_project_mutation_locked( + &root, + "code-prototype", + child_run_id, + "file.write", + ) + .expect("record project mutation"); + assert_eq!(revision, 1); + let (expected_revision, verification_gate) = begin_agent_runtime_project_verification_locked( + &root, + "code-prototype", + child_run_id, + "project.verify", + ) + .expect("begin failed verification fixture"); + finish_agent_runtime_project_verification_locked( + &root, + &expected_revision, + verification_gate, + false, + ) + .expect("finish failed verification fixture"); + let revision = prepare_agent_runtime_project_mutation_locked( + &root, + "code-prototype", + child_run_id, + "file.patch", + ) + .expect("record repair after failed verification"); + assert_eq!(revision, 2); + let mut observations = vec![ + AgentRuntimeToolObservation { + tool: "file.write".to_string(), + status: "ok".to_string(), + summary: "已写入 game/index.html".to_string(), + detail: None, + }, + AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: "test 验证失败,退出码 1".to_string(), + detail: None, + }, + AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "ok".to_string(), + summary: "已根据失败诊断修复 game/index.html".to_string(), + detail: None, + }, + ]; + observations.extend((0..AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT).map(|_| { + AgentRuntimeToolObservation { + tool: "runtime.plan_update".to_string(), + status: "blocked".to_string(), + summary: "结构化计划尚未完成".to_string(), + detail: None, + } + })); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + child_run_id, + &runtime.current_task, + &observations, + 20, + 0, + ) + .await + .expect("repair post-mutation read loop") + .expect("repaired verification plan"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "project.verify"); + + let _initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial post-mutation request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("post-mutation verification repair request"); + assert!(repair_request.contains("最近一次项目修改尚未重新验证")); + assert!(repair_request.contains("旧诊断不再代表当前 revision")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("restricted post-mutation repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([ + native_runtime_function_name("project.verify") + .expect("verify function") + .as_str(), + native_runtime_function_name("command.run_limited") + .expect("limited command function") + .as_str(), + ]) + ); + assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(!repair_function_names.contains( + native_runtime_function_name("project.index") + .expect("index function") + .as_str() + )); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mutation() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-supervisor-playtest-liveness", + "自主构建总控试玩失败活性测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("disable unrelated collaboration preflight"); + let run_id = "autonomous-supervisor-playtest-liveness-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "修复试玩失败并完成可玩塔防", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "根据试玩诊断直接修复", + vec!["修复试玩失败".to_string(), "重新验证并交付".to_string()], + ) + .expect("start autonomous supervisor runtime"); + + let (sender, receiver) = mpsc::channel(); + let read_arguments = serde_json::json!({"reason": "继续读取而不修复", "input": {}}).to_string(); + let patch_arguments = serde_json::json!({ + "reason": "停止空转并直接修复试玩失败", + "input": { + "path": "game/index.html", + "oldText": "ctx.", + "newText": "ctx.fillRect(0,0,1,1);" + } + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-supervisor-playtest-read", + &native_runtime_function_name("project.index").expect("index function"), + read_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-supervisor-playtest-patch", + &native_runtime_function_name("file.patch").expect("patch function"), + patch_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "autonomous-supervisor-playtest-repair-key", + "baseUrl": {base_url:?}, + "model": "autonomous-supervisor-playtest-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let mut observations = vec![AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器验证未通过,请根据诊断修复后重试".to_string(), + detail: Some(r#"{"passed":false,"diagnostics":["缺少可玩状态"]}"#.to_string()), + }]; + observations.extend((0..AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT).map(|_| { + AgentRuntimeToolObservation { + tool: "runtime.plan_update".to_string(), + status: "blocked".to_string(), + summary: "结构化计划尚未完成".to_string(), + detail: None, + } + })); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &observations, + 30, + 0, + ) + .await + .expect("repair failed playtest stall") + .expect("repaired supervisor mutation plan"); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "file.patch"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial stalled supervisor request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("failed playtest mutation repair request"); + assert!(repair_request.contains("最近一次交互试玩仍未通过")); + assert!(repair_request.contains(&format!( + "失败后已有 {AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT} 条非修改观察" + ))); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("restricted failed playtest repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + for tool in [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + ] { + assert!(repair_function_names.contains( + native_runtime_function_name(tool) + .expect("mutation function") + .as_str() + )); + } + assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + for tool in [ + "project.index", + "project.verify", + "command.run_limited", + "preview.validate", + "agent.delegate", + ] { + assert!(!repair_function_names.contains( + native_runtime_function_name(tool) + .expect("excluded function") + .as_str() + )); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-preview-gate", "试玩失败凭证失效测试") + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write default collaboration policy"); + let run_id = "preview-gate-failure-run"; + let revision = prepare_agent_runtime_project_mutation_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "file.write", + ) + .expect("prepare project mutation"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "game.static_smoke", + ) + .expect("begin static smoke"); + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect("finish static smoke"); + + invalidate_agent_runtime_project_verification_after_preview_failure_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + revision, + ) + .expect("invalidate failed browser playtest"); + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read invalidated gate"); + assert!(gate.requires_verification); + assert_eq!(gate.mutation_revision, Some(revision)); + assert_eq!(gate.verified_revision, None); + assert_eq!( + gate.last_verification_tool.as_deref(), + Some("preview.validate") + ); + assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_profile_blocks_user_input_before_waiting_state() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-autonomous-input", "自主构建提问门禁项目") + .expect("project init"); + let run_id = "autonomous-user-input-run"; + let binding = 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 profile"); + let mut runtime = + default_game_creator_agent_runtime_state(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id); + runtime.source = AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string(); + runtime.run_profile = binding.profile; + runtime.run_profile_binding_fingerprint = binding.binding_fingerprint; + runtime.status = "running".to_string(); + runtime.phase = "planning".to_string(); + runtime.current_task = "生成一版可直接试玩的塔防游戏".to_string(); + let plan = AgentRuntimeToolPlan { + thinking_summary: "尝试向用户追问".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(), + reason: Some("询问视觉风格".to_string()), + input: serde_json::json!({ + "questions": [{ + "id": "visual_style", + "header": "视觉风格", + "question": "请选择视觉风格", + "options": [ + {"label": "卡通", "description": "使用明亮卡通风格"}, + {"label": "像素", "description": "使用复古像素风格"} + ] + }] + }), + }], + response: String::new(), + }; + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &read_game_creator_agent_runtime_project_revision(&root).expect("project revision"), + &"a".repeat(64), + ) + .await + .expect("prepare autonomous action batch"); + match preparation { + AgentRuntimeProviderActionBatchPreparation::Blocked(observation) => { + assert_eq!(observation.tool, GAME_CREATOR_USER_INPUT_REQUEST_TOOL); + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("禁止中途请求用户输入")); + } + other => panic!("expected user input block, got {other:?}"), + } + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + )); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_tool_action_respects_agent_policy() { let root = unique_project_path(); @@ -8035,6 +9734,431 @@ fn supervisor_collaboration_plan_for_test( } } +#[tokio::test] +async fn supervisor_collaboration_empty_initial_plan_repairs_into_required_static_wave() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-supervisor-empty-initial-plan-repair", + "总控首轮空计划修复测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 2, + required_static_agent_ids: vec![ + "art-director".to_string(), + "design-director".to_string(), + ], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write required static collaboration policy"); + + let (sender, receiver) = mpsc::channel(); + let update_arguments = serde_json::json!({ + "explanation": "先规划专业协作", + "steps": [ + {"step": "并行委派策划与美术", "status": "in_progress"}, + {"step": "汇总专业交付", "status": "pending"} + ] + }) + .to_string(); + let delegate_function = + native_runtime_function_name("agent.delegate").expect("delegate function"); + let design_arguments = serde_json::json!({ + "reason": "委派策划 Agent", + "input": { + "agentId": "design-director", + "task": "输出可执行的玩法设计", + "acceptanceCriteria": ["玩法设计可以直接进入实现"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let art_arguments = serde_json::json!({ + "reason": "委派美术 Agent", + "input": { + "agentId": "art-director", + "task": "输出可执行的美术规范", + "acceptanceCriteria": ["美术规范可以直接进入制作"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-supervisor-plan-only", + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + update_arguments, + ), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-design-delegate", + delegate_function.as_str(), + design_arguments, + ), + ( + "call-supervisor-art-delegate", + delegate_function.as_str(), + art_arguments, + ), + ]), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "supervisor-empty-plan-repair-key", + "baseUrl": {base_url:?}, + "model": "supervisor-empty-plan-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "supervisor-empty-initial-plan-repair-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "并行完成策划与美术首批协作", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "生成首批协作计划", + vec!["一次性委派两个专业 Agent".to_string()], + ) + .expect("start supervisor runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("repair empty initial collaboration plan") + .expect("repaired collaboration plan"); + assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions[0].tool, "agent.delegate"); + assert_eq!(plan.actions[0].input["agentId"], "design-director"); + assert_eq!(plan.actions[1].tool, "agent.delegate"); + assert_eq!(plan.actions[1].input["agentId"], "art-director"); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial supervisor plan request"); + assert!(initial_request.contains("requiredStaticAgentIds")); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("supervisor collaboration repair request"); + assert!(repair_request.contains("首批协作不能停留在计划更新")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("restricted empty collaboration repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([ + delegate_function.as_str(), + native_runtime_function_name("agent.spawn_isolated") + .expect("isolated function") + .as_str(), + ]) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let records = read_agent_db_records_for_test(&root); + let repairs = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + let protocol = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .expect("repaired collaboration protocol audit"); + assert_eq!(protocol["repairAttempt"], 1); + assert_eq!(protocol["functionCallCount"], 2); + assert_eq!( + protocol["functionNames"], + serde_json::json!([delegate_function, delegate_function]) + ); + let collaboration_state = read_supervisor_collaboration_state_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read untouched collaboration state"); + assert!(!collaboration_state.has_collaboration()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_read_only_first_window_repairs_with_collaboration_tools_only() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-supervisor-read-only-initial-window-repair", + "总控首轮只读逃逸修复测试", + ) + .expect("project init"); + let (sender, receiver) = mpsc::channel(); + let update_arguments = serde_json::json!({ + "explanation": "继续读取项目后再决定委派", + "steps": [ + {"step": "读取项目入口", "status": "in_progress"}, + {"step": "建立专业协作", "status": "pending"} + ] + }) + .to_string(); + let read_arguments = + serde_json::json!({"reason": "继续读取项目索引", "input": {}}).to_string(); + let delegate_function = + native_runtime_function_name("agent.delegate").expect("delegate function"); + let isolated_function = + native_runtime_function_name("agent.spawn_isolated").expect("isolated function"); + let code_arguments = serde_json::json!({ + "reason": "委派原型实现 Agent", + "input": { + "agentId": "code-prototype", + "task": "实现可直接试玩的游戏原型", + "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], + "expectedArtifacts": ["game/index.html"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let quality_arguments = serde_json::json!({ + "reason": "委派质量评审 Agent", + "input": { + "agentId": "quality-review", + "task": "评审可玩性与闯关闭环", + "acceptanceCriteria": ["指出阻塞试玩的具体问题并给出验收结论"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-read-only-plan", + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + update_arguments, + ), + ( + "call-supervisor-read-only-index", + native_runtime_function_name("project.index") + .expect("index function") + .as_str(), + read_arguments, + ), + ]), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-read-only-code-delegate", + delegate_function.as_str(), + code_arguments, + ), + ( + "call-supervisor-read-only-quality-delegate", + delegate_function.as_str(), + quality_arguments, + ), + ]), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "supervisor-read-only-window-repair-key", + "baseUrl": {base_url:?}, + "model": "supervisor-read-only-window-repair-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "supervisor-read-only-initial-window-repair-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "并行完成原型实现与质量评审", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "读取后建立首批协作", + vec!["读取必要上下文".to_string(), "一次性委派两个专业 Agent".to_string()], + ) + .expect("start supervisor runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1, + 0, + ) + .await + .expect("repair read-only initial collaboration plan") + .expect("repaired collaboration plan"); + assert_eq!(plan.actions.len(), 2); + assert!(plan.actions.iter().all(|action| action.tool == "agent.delegate")); + assert_eq!(plan.actions[0].input["agentId"], "code-prototype"); + assert_eq!(plan.actions[1].input["agentId"], "quality-review"); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial supervisor read-only request"); + assert!(initial_request.contains("requiredStaticAgentIds")); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("supervisor read-only collaboration repair request"); + assert!(repair_request.contains("当前已到第 7 轮")); + assert!(repair_request.contains("不得继续只更新计划")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("restricted supervisor collaboration repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let records = read_agent_db_records_for_test(&root); + let repairs = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + assert_eq!(repairs[0]["repairAttempt"], 0); + let protocol = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .expect("repaired collaboration protocol audit"); + assert_eq!(protocol["repairAttempt"], 1); + assert_eq!(protocol["functionCallCount"], 2); + + let collaboration_state = read_supervisor_collaboration_state_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read untouched collaboration state"); + assert!(!collaboration_state.has_collaboration()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-default-collaboration", + "自主构建缺省协作策略测试", + ) + .expect("project init"); + let run_id = "supervisor-autonomous-default-collaboration-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve autonomous default collaboration policy"); + assert_eq!(resolution.source, "autonomous-run-default"); + assert_eq!(resolution.project_policy_status, "absent"); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Static + ); + assert_eq!(resolution.policy.min_static_delegates, 2); + assert_eq!( + resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string(), "quality-review".to_string()] + ); + assert!(!root + .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) + .exists()); + + fs::remove_dir_all(root).ok(); +} + fn downgrade_supervisor_collaboration_batch_to_v1_for_test( mut batch: AgentRuntimeProviderActionBatch, ) -> AgentRuntimeProviderActionBatch { @@ -10504,6 +12628,8 @@ async fn supervisor_collaboration_v2_batch_recovers_durable_isolated_spawn_witho session_id: instance.session_id.clone(), run_id: instance.run_id.clone(), source: AGENT_RUNTIME_ISOLATED_CHILD_SOURCE.to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), parent_run_id: Some(run_id.to_string()), delegation_id: Some(instance.delegation_id.clone()), @@ -17232,6 +19358,8 @@ async fn background_agent_runtime_recovers_stale_running_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-recover-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -17316,6 +19444,8 @@ async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed session_id: "agent-session-design-director".to_string(), run_id: "design-confirm-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -17421,6 +19551,8 @@ fn background_agent_runtime_legacy_waiting_task_blocks_pending_recovery() { session_id: "agent-session-design-director".to_string(), run_id: "design-after-legacy-waiting-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -17489,6 +19621,8 @@ async fn background_agent_runtime_recovers_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-pending-recover-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -17573,6 +19707,8 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-stale-running-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -17594,6 +19730,8 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { session_id: "agent-session-design-director".to_string(), run_id: "design-pending-after-stale-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -20099,6 +22237,8 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { session_id: parent_state.session_id.clone(), run_id: group.join_run_id.clone(), source: AGENT_RUNTIME_ISOLATED_JOIN_SOURCE.to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: Some(parent_state.run_id.clone()), delegation_id: Some(group.delegation_group_id.clone()), @@ -20222,6 +22362,8 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { session_id: "agent-session-art-director".to_string(), run_id: run_id.clone(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("delegate-parent-run".to_string()), delegation_id: Some(delegation_id.clone()), @@ -20297,6 +22439,8 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { session_id: "agent-session-art-director".to_string(), run_id: "delegated-terminal-queued-cancel".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("delegate-parent-run".to_string()), delegation_id: Some("delegation-terminal-queued-cancel".to_string()), @@ -20327,6 +22471,8 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { session_id: "agent-session-art-director".to_string(), run_id: "delegated-terminal-recovery".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("delegate-parent-run".to_string()), delegation_id: Some("delegation-terminal-recovery".to_string()), @@ -20353,6 +22499,8 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { session_id: "agent-session-art-director".to_string(), run_id: "delegated-terminal-reconciliation".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("delegate-parent-run".to_string()), delegation_id: Some("delegation-terminal-reconciliation".to_string()), @@ -20628,6 +22776,8 @@ fn delegated_agent_receipt_publication_is_concurrency_safe() { session_id: "agent-session-art-director".to_string(), run_id: "delegate-concurrent-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("delegate-concurrent-parent-run".to_string()), delegation_id: Some("delegation-concurrent-1".to_string()), @@ -20704,6 +22854,8 @@ fn delegated_agent_receipt_does_not_revive_cancelled_parent() { session_id: "agent-session-art-director".to_string(), run_id: "cancelled-parent-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("cancelled-delegate-parent-run".to_string()), delegation_id: Some("cancelled-parent-delegation".to_string()), @@ -20770,6 +22922,8 @@ fn delegated_agent_receipt_keeps_complete_terminal_detail_for_parent() { session_id: "agent-session-art-director".to_string(), run_id: "complete-detail-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("complete-detail-parent-run".to_string()), delegation_id: Some("complete-detail-delegation".to_string()), @@ -20848,6 +23002,8 @@ async fn queued_delegate_receipt_is_suppressed_when_parent_cancels_before_drain( session_id: "agent-session-art-director".to_string(), run_id: "queued-receipt-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("queued-receipt-parent-run".to_string()), delegation_id: Some("queued-receipt-delegation".to_string()), @@ -20937,6 +23093,8 @@ async fn delegate_receipt_without_parent_link_fails_closed_before_execution() { session_id, run_id: "missing-parent-link-receipt".to_string(), source: "agent-delegate-receipt".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: Some("missing-parent-link-delegation".to_string()), @@ -21223,6 +23381,8 @@ schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), session_id: "agent-session-art-director".to_string(), run_id: "redacted-delegate-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("redacted-delegate-parent-run".to_string()), delegation_id: Some("redacted-delegation".to_string()), @@ -23262,9 +25422,7 @@ async fn background_agent_runtime_can_run_limited_static_smoke() { .recv_timeout(Duration::from_secs(2)) .expect("final reply llm request"); assert!(final_request.contains("game.static_smoke 已完成")); - assert!(final_request.contains("通过:")); - assert!(final_request.contains("$PROJECT_ROOT/game/index.html")); - assert!(final_request.contains("game/index.html")); + assert!(final_request.contains("通过:game/index.html")); let root_display = root.to_string_lossy(); assert!(!final_request.contains(root_display.as_ref())); @@ -30075,6 +32233,8 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { session_id: "agent-session-art-director".to_string(), run_id: "reconciliation-child-terminal-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some(run_id.to_string()), delegation_id: Some("reconciliation-child-delegation".to_string()), @@ -33497,6 +35657,96 @@ async fn provider_transient_retry_transport_failure_closes_then_stable_retry_suc fs::remove_dir_all(root).ok(); } +#[test] +fn provider_retry_waiting_same_attempt_for_distinct_requests_does_not_conflict() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-retry-waiting-identity", "重试等待身份项目") + .expect("project init"); + let run_id = "provider-retry-waiting-distinct-request-run"; + let mut runtime = default_game_creator_agent_runtime_state("code-prototype", run_id); + runtime.source = "agent-background-task".to_string(); + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-provider-retry".to_string(); + let record = |request_kind: &str, base_request_slot: &str| { + let now = provider_retry::now_ms(); + provider_retry::AgentRuntimeProviderRetryRecord { + schema_version: provider_retry::PROVIDER_RETRY_SCHEMA_VERSION.to_string(), + identity: provider_retry::AgentRuntimeProviderRetryIdentity { + project_id: "project-retry-waiting-identity".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(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: request_kind.to_string(), + base_request_slot: base_request_slot.to_string(), + request_fingerprint: "a".repeat(64), + provider_config_fingerprint: "b".repeat(64), + web_search_enabled: false, + allow_idle_context_compaction: false, + }, + next_request_slot: format!("{base_request_slot}-transient-1"), + next_attempt: 1, + max_retries: 12, + backoff_ms: 100, + retry_at_ms: now.saturating_add(100), + error_kind: "transport".to_string(), + error_fingerprint: "c".repeat(64), + created_at_ms: now, + updated_at_ms: now, + } + }; + let compaction = record("context-compaction", "context-compaction-1"); + let tool_plan = record("tool-plan", "loop-7-repair-0"); + + ensure_waiting_provider_retry_records_for_test(&root, &runtime, &compaction) + .expect("persist compaction retry waiting audit"); + ensure_waiting_provider_retry_records_for_test(&root, &runtime, &tool_plan) + .expect("persist distinct tool-plan retry waiting audit"); + ensure_waiting_provider_retry_records_for_test(&root, &runtime, &tool_plan) + .expect("replay identical tool-plan retry waiting audit"); + + let records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry_waiting" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(records.len(), 2); + assert_eq!( + records + .iter() + .map(|record| ( + record["requestKind"].as_str().unwrap_or_default(), + record["baseRequestSlot"].as_str().unwrap_or_default(), + record["nextRequestSlot"].as_str().unwrap_or_default(), + record["nextAttempt"].as_u64().unwrap_or_default(), + )) + .collect::>(), + BTreeSet::from([ + ( + "context-compaction", + "context-compaction-1", + "context-compaction-1-transient-1", + 1, + ), + ( + "tool-plan", + "loop-7-repair-0", + "loop-7-repair-0-transient-1", + 1, + ), + ]) + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_sidecar() { let root = unique_project_path(); @@ -34466,6 +36716,173 @@ async fn provider_transient_retry_provider_error_is_not_retried() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试") + .expect("project init"); + let delegate_function = + native_runtime_function_name("agent.delegate").expect("delegate function"); + let code_arguments = serde_json::json!({ + "reason": "委派可玩原型实现", + "input": { + "agentId": "code-prototype", + "task": "实现可直接试玩的游戏原型", + "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], + "expectedArtifacts": ["game/index.html"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let quality_arguments = serde_json::json!({ + "reason": "委派独立质量评审", + "input": { + "agentId": "quality-review", + "task": "评审可玩性与闯关闭环", + "acceptanceCriteria": ["给出阻塞试玩的问题和验收结论"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let recovered_response = native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-autonomous-upstream-400-code", + delegate_function.as_str(), + code_arguments, + ), + ( + "call-autonomous-upstream-400-quality", + delegate_function.as_str(), + quality_arguments, + ), + ]); + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_upstream_400_then_raw_response( + recovered_response, + Some(request_notice_sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "supervisor-autonomous-upstream-400-key", + "baseUrl": {base_url:?}, + "model": "supervisor-autonomous-upstream-400-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "supervisor-autonomous-upstream-400-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "自主生成一版可以正常闯关的塔防游戏", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "建立首批协作", + vec!["并行实现和评审可玩原型".to_string()], + ) + .expect("start autonomous Supervisor runtime"); + + let waiting = request_game_creator_agent_background_tool_plan_waiting_retry_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("autonomous upstream 400 enters retry wait"); + request_notice_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial autonomous upstream 400 request"); + assert_eq!(waiting.error_kind, "upstream-400"); + assert_eq!(waiting.next_attempt, 1); + assert_eq!( + waiting.max_retries, + AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT + ); + provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity) + .expect("force autonomous upstream 400 retry due"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("autonomous upstream 400 retry succeeds") + .expect("recovered collaboration plan"); + request_notice_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("recovered autonomous Provider request"); + assert!(request_notice_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + assert_eq!(plan.actions.len(), 2); + assert!(plan.actions.iter().all(|action| action.tool == "agent.delegate")); + assert!(provider_retry::read_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read cleared autonomous Provider retry") + .is_none()); + + let records = read_agent_db_records_for_test(&root); + let retry_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(retry_audits.len(), 1); + assert_eq!(retry_audits[0]["errorKind"], "upstream-400"); + assert_eq!( + retry_audits[0]["maxRetries"], + AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT + ); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "failed"); + assert_eq!(lifecycle[2]["status"], "started"); + assert_eq!(lifecycle[3]["status"], "completed"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn provider_transient_retry_protocol_error_uses_format_repair_not_transport_retry() { let root = unique_project_path(); @@ -41555,6 +43972,8 @@ async fn background_agent_runtime_starts_oldest_pending_task_after_lock_acquisit session_id: "agent-session-design-director".to_string(), run_id: "design-fifo-first-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -42018,6 +44437,8 @@ fn delegated_agent_retry_is_rejected_after_parent_runtime_terminates() { session_id: "terminal-project-supervisor-session".to_string(), run_id: parent_run_id.to_string(), source: "project-supervisor".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -42043,6 +44464,8 @@ fn delegated_agent_retry_is_rejected_after_parent_runtime_terminates() { session_id: "failed-code-child-session".to_string(), run_id: child_run_id.to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("project-supervisor".to_string()), parent_run_id: Some(parent_run_id.to_string()), delegation_id: Some("failed-code-child-delegation".to_string()), @@ -46860,6 +49283,8 @@ fn agent_conversation_session_fork_accepts_archived_source_and_rejects_live_lane session_id: "agent-session-art-director".to_string(), run_id: "fork-delegated-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("fork-live-run".to_string()), delegation_id: Some("fork-delegation".to_string()), @@ -46908,6 +49333,8 @@ fn agent_conversation_session_fork_accepts_archived_source_and_rejects_live_lane session_id: source_session_id.clone(), run_id: "fork-nonactive-source-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -47132,6 +49559,8 @@ fn agent_conversation_session_fork_fails_closed_on_invalid_task_journals() { session_id: "agent-session-design-director".to_string(), run_id: "fork-unknown-status-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -47275,6 +49704,8 @@ fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() { session_id: "agent-session-art-director".to_string(), run_id: "archive-delegated-child-run".to_string(), source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: Some("design-director".to_string()), parent_run_id: Some("archive-parent-run".to_string()), delegation_id: Some("archive-delegation".to_string()), @@ -47505,6 +49936,8 @@ async fn agent_runtime_conversation_tool_uses_run_session() { session_id: active.active_session_id.clone(), run_id: "other-session-task".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -47627,6 +50060,8 @@ fn agent_runtime_retry_preserves_session_returns_ack_and_reuses_active_successor session_id: original_session_id.clone(), run_id: "failed-original-run".to_string(), source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -48807,13 +51242,24 @@ fn limited_local_command_runs_static_game_smoke_and_writes_log() { assert_eq!(result.command_id, "game.static_smoke"); assert_eq!(result.status, "completed", "{}", result.output); assert!(result.output.contains("game/index.html")); + assert_eq!(result.log_path, ".agent/logs/command.log"); + assert!(!result.output.contains(root.to_string_lossy().as_ref())); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("command.run_limited game.static_smoke")); + assert!(!log.contains(root.to_string_lossy().as_ref())); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); assert_eq!(manifest["commandRuns"][0]["commandId"], "game.static_smoke"); assert_eq!(manifest["commandRuns"][0]["status"], "completed"); + assert_eq!( + manifest["commandRuns"][0]["logPath"], + ".agent/logs/command.log" + ); + assert!(!manifest["commandRuns"][0]["output"] + .as_str() + .unwrap_or_default() + .contains(root.to_string_lossy().as_ref())); fs::remove_dir_all(root).ok(); } @@ -49429,6 +51875,31 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { fs::remove_dir_all(root).ok(); } +#[test] +fn limited_local_command_rejects_token_rich_truncated_script() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); + let html = r#" + + + +

目标:阻挡敌人。胜利或失败后可以 Restart。

+