import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createDeterministicLaneDefenseRouter, deterministicLaneDefenseInitialHtml, deterministicLaneDefenseModel, hiddenCanvasCss, startDeterministicLaneDefenseProvider, visibleCanvasCss, } from './deterministic-lane-defense-provider.mjs'; import { withLoopbackNoProxy } from './llm-transient-fault-proxy.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = path.resolve(appRoot, '../..'); const realE2eScript = path.join(appRoot, 'scripts/agent-runtime-real-e2e.mjs'); const suite = 'supervisor-autonomous-playable-lane-defense'; const wrapperSuite = 'supervisor-autonomous-playable-lane-defense-deterministic'; const configFileName = 'game-creator.config.json'; const configSentinelName = '.deterministic-provider-e2e.json'; const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1'; const outputLimit = 32 * 1024 * 1024; function hashValue(value) { return createHash('sha256').update(value).digest('hex'); } function assert(condition, code) { if (condition) return; const error = new Error(code); error.code = code; throw error; } function parseArguments(args) { let keepProject = false; let selfTest = false; for (const arg of args) { if (arg === '--keep-project') keepProject = true; else if (arg === '--self-test') selfTest = true; else throw new Error('unknown-argument'); } assert(!(keepProject && selfTest), 'self-test-keep-project-conflict'); return { keepProject, selfTest }; } function appendBounded(current, chunk) { const combined = Buffer.concat([current, chunk]); if (combined.length > outputLimit) throw new Error('child-output-too-large'); return combined; } function runChild(args, environment) { return new Promise((resolve) => { const child = spawn(process.execPath, args, { cwd: appRoot, env: environment, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); let outputError = null; child.stdout.on('data', (chunk) => { try { stdout = appendBounded(stdout, chunk); } catch (error) { outputError = error; child.kill('SIGTERM'); } }); child.stderr.on('data', (chunk) => { try { stderr = appendBounded(stderr, chunk); } catch (error) { outputError = error; child.kill('SIGTERM'); } }); child.once('error', (error) => resolve({ code: null, signal: null, error, stdout, stderr }), ); child.once('close', (code, signal) => resolve({ code, signal, error: outputError, stdout, stderr }), ); }); } function safeChildDiagnostic(result) { return { exitCode: Number.isInteger(result.code) ? result.code : null, signal: typeof result.signal === 'string' ? result.signal : null, errorCode: typeof result.error?.code === 'string' ? result.error.code : null, stdoutBytes: result.stdout.length, stdoutSha256: hashValue(result.stdout), stderrBytes: result.stderr.length, stderrSha256: hashValue(result.stderr), }; } function parseChildReport(result) { try { const report = JSON.parse(result.stdout.toString('utf8')); assert(report && typeof report === 'object', 'child-report-root-invalid'); return report; } catch (error) { if (error?.code) throw error; const wrapped = new Error('child-report-json-invalid'); wrapped.code = 'child-report-json-invalid'; throw wrapped; } } function expectedProviderStats(stats) { return ( stats.requestCount === 17 && stats.planningRequestCount === 17 && stats.finalReplyRequestCount === 0 && stats.initialDelegationCount === 2 && stats.followupDelegationCount === 1 && stats.runStatusCount === 2 && stats.sourceWriteCount === 1 && stats.sourcePatchCount === 1 && stats.staticSmokeCount === 4 && stats.previewValidationCount === 2 && stats.supervisorDirectMutationAttemptCount === 1 && stats.unexpectedRequestCount === 0 && Object.keys(stats.rejectionCodes ?? {}).length === 0 && stats.byAgent?.['project-supervisor']?.planning === 9 && stats.byAgent?.['project-supervisor']?.finalReply === 0 && stats.byAgent?.['code-prototype']?.planning === 6 && stats.byAgent?.['code-prototype']?.finalReply === 0 && stats.byAgent?.['quality-review']?.planning === 2 && stats.byAgent?.['quality-review']?.finalReply === 0 ); } function syntheticPayload(agentId, runId, tools, extraContext = '') { return { model: deterministicLaneDefenseModel, stream: false, messages: [ { role: 'user', content: `- templateAgentId: ${agentId}\n- runId: ${runId}\n${extraContext}`, }, ], tools: tools.map((name) => ({ type: 'function', function: { name } })), }; } function responseFunctionNames(response) { return response.choices[0].message.tool_calls.map( (call) => call.function.name, ); } async function runSelfTest() { const html = deterministicLaneDefenseInitialHtml(); assert([...html].length <= 8_000, 'self-test-html-source-budget-invalid'); assert( html.includes(hiddenCanvasCss) && !html.includes(visibleCanvasCss) && html.includes('playable-web-game-state.v1') && html.includes('data-playtest-id="next-level"') && html.includes('Goal: defend the garden and win every wave.') && html.includes('requestAnimationFrame'), 'self-test-html-contract-invalid', ); const apiKey = `deterministic-self-test-${randomUUID()}`; const router = createDeterministicLaneDefenseRouter({ apiKey }); const allTools = [ 'update_agent_plan', 'respond_to_user', 'runtime_tool_agent_delegate', 'runtime_tool_agent_run_status', 'runtime_tool_command_run_limited', 'runtime_tool_file_patch', 'runtime_tool_file_write', 'runtime_tool_preview_validate', ]; const route = (agentId, runId, tools = allTools, extraContext = '') => router.route({ authorization: `Bearer ${apiKey}`, payload: syntheticPayload(agentId, runId, tools, extraContext), }); assert( responseFunctionNames(route('project-supervisor', 'parent-run')).join( ',', ) === 'runtime_tool_agent_delegate,runtime_tool_agent_delegate', 'self-test-initial-delegation-invalid', ); const qualityPlan = '计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无'; const builderPlan = '计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无'; route('quality-review', 'quality-run', allTools, qualityPlan); route('code-prototype', 'initial-code-run'); route('code-prototype', 'initial-code-run'); assert( responseFunctionNames( route('code-prototype', 'initial-code-run', allTools, builderPlan), ).join(',') === 'update_agent_plan,respond_to_user', 'self-test-builder-plan-completion-invalid', ); assert( responseFunctionNames( route('quality-review', 'quality-run', allTools, qualityPlan), ).join(',') === 'update_agent_plan,respond_to_user', 'self-test-quality-stale-replan-invalid', ); route('project-supervisor', 'parent-run'); route('project-supervisor', 'parent-run'); route('project-supervisor', 'parent-run'); assert( responseFunctionNames(route('project-supervisor', 'parent-run'))[0] === 'runtime_tool_file_patch', 'self-test-forbidden-parent-mutation-invalid', ); assert( responseFunctionNames( route( 'project-supervisor', 'parent-run', ['runtime_tool_agent_delegate'], '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate', ), )[0] === 'runtime_tool_agent_delegate', 'self-test-followup-delegation-invalid', ); route('code-prototype', 'repair-code-run'); route('code-prototype', 'repair-code-run'); route('code-prototype', 'repair-code-run', allTools, builderPlan); assert( responseFunctionNames(route('project-supervisor', 'parent-run'))[0] === 'runtime_tool_agent_run_status', 'self-test-repair-claim-before-verification-invalid', ); assert( responseFunctionNames(route('project-supervisor', 'parent-run')).join( ',', ) === 'runtime_tool_command_run_limited,runtime_tool_preview_validate', 'self-test-repair-verification-batch-invalid', ); route('project-supervisor', 'parent-run'); const stats = router.getStats(); assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid'); const rootPackage = JSON.parse( await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'), ); const shellPackage = JSON.parse( await fs.readFile(path.join(appRoot, 'package.json'), 'utf8'), ); assert( shellPackage.scripts?.[ 'agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e' ] === 'node scripts/agent-runtime-deterministic-playable-e2e.mjs' && rootPackage.scripts?.[ 'ai-game-creator-shell:agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e' ] === 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --', 'self-test-package-command-invalid', ); return { status: 'PASS', suite: `${wrapperSuite}-self-test`, providerUsed: false, htmlChars: [...html].length, providerStats: stats, packageCommandsRegistered: true, }; } async function runE2e(options) { const token = randomUUID(); const apiKey = `deterministic-runtime-${randomUUID()}`; const configDir = await fs.mkdtemp( path.join(os.tmpdir(), 'genarrative-deterministic-provider-config-'), ); let provider = null; let childResult = null; let childReport = null; let configRemoved = false; let providerStats = null; let failureCode = null; try { if (process.platform !== 'win32') await fs.chmod(configDir, 0o700); await fs.writeFile( path.join(configDir, configSentinelName), `${JSON.stringify({ schemaVersion: configSentinelSchema, token })}\n`, { flag: 'wx', mode: 0o600 }, ); provider = await startDeterministicLaneDefenseProvider({ apiKey }); const config = { llm: { apiKey, baseUrl: provider.baseUrl, model: deterministicLaneDefenseModel, apiKind: 'openai_chat', reasoningEffort: 'low', stream: false, requestTimeoutMs: 30_000, maxRetries: 0, retryBackoffMs: 100, }, }; await fs.writeFile( path.join(configDir, configFileName), `${JSON.stringify(config)}\n`, { flag: 'wx', mode: 0o600 }, ); const childArgs = [ realE2eScript, '--suite', suite, '--config-dir', configDir, ]; if (options.keepProject) childArgs.push('--keep-project'); childResult = await runChild( childArgs, withLoopbackNoProxy({ ...process.env, NO_COLOR: '1' }), ); childReport = parseChildReport(childResult); } catch (error) { failureCode = error?.code ?? 'deterministic-e2e-unexpected-error'; } finally { if (provider) { try { await provider.stop(); providerStats = provider.getStats(); } catch { failureCode ??= 'deterministic-provider-stop-failed'; } } try { const sentinel = JSON.parse( await fs.readFile(path.join(configDir, configSentinelName), 'utf8'), ); assert( sentinel.schemaVersion === configSentinelSchema && sentinel.token === token, 'deterministic-config-sentinel-invalid', ); await fs.rm(configDir, { recursive: true, force: false }); configRemoved = true; } catch (error) { failureCode ??= error?.code ?? 'deterministic-config-cleanup-failed'; } } const childPassed = childResult?.code === 0 && childResult?.signal === null && !childResult?.error && childReport?.status === 'PASS' && childReport?.suite === suite && childReport?.cleanup?.performed === !options.keepProject && childReport?.cleanup?.kept === options.keepProject; const providerPassed = providerStats?.stopped === true && expectedProviderStats(providerStats); const status = !failureCode && childPassed && providerPassed && configRemoved ? 'PASS' : 'FAIL'; if (status !== 'PASS' && !failureCode) { failureCode = !childPassed ? 'deterministic-child-e2e-failed' : !providerPassed ? 'deterministic-provider-contract-failed' : 'deterministic-config-not-cleaned'; } return { status, suite: wrapperSuite, providerMode: 'deterministic-loopback-openai-chat', delegatedSuite: suite, child: childReport, provider: providerStats, cleanup: { providerStopped: providerStats?.stopped === true, configRemoved, projectKept: options.keepProject, }, childDiagnostic: childResult ? safeChildDiagnostic(childResult) : null, failureCode, }; } const options = parseArguments(process.argv.slice(2)); const report = options.selfTest ? await runSelfTest() : await runE2e(options); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exitCode = report.status === 'PASS' ? 0 : 1;