Files
Genarrative/apps/ai-game-creator-shell/scripts/agent-runtime-steer-real-e2e.mjs
AIGameCreator App 52d47d5470
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 2m21s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / Native shell tests (push) Failing after 9m35s
合并远端AI游戏创作分支更新
保留 Agent Runtime、Runner、项目与测试的模块化拆分
吸收跨平台启动与本地开发栈稳定性修复
补齐 Gitea CI 门禁与异步测试稳定性改进
统一前端 Runtime 水合、策略读取与状态重置语义
2026-07-23 12:40:28 +08:00

423 lines
13 KiB
JavaScript

import { spawn } from 'node:child_process';
import { 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';
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const repoRoot = path.resolve(appRoot, '../..');
const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml');
const agentId = 'code-prototype';
const runId = `steer-real-${Date.now()}-${randomUUID().slice(0, 8)}`;
const projectRoot = path.join(os.tmpdir(), `genarrative-${runId}`);
const marker = `STEER_REAL_${randomUUID().replaceAll('-', '').toUpperCase()}`;
const instruction = `运行中补充:继续保持只读,不调用写入、命令、预览或生成工具;最终回复必须原样包含 ${marker},并明确说明已按追加指令更新。`;
const timeoutMs = 8 * 60 * 1000;
const pollMs = 200;
const options = parseArguments(process.argv.slice(2));
const evidence = {
providerInterrupted: false,
steerAttempts: 0,
taskRunIds: [],
steerStatuses: [],
userMessageCount: 0,
assistantMessageCount: 0,
publicInstructionLeakCount: 0,
loadedKeyLeakCount: 0,
};
let status = 'FAIL';
let error = null;
let binary = null;
try {
const config = await loadConfig(options.configDir);
const secrets = collectSecrets(config);
assert(isAgentConfigured(config, agentId), 'llm-not-configured');
binary = await prepareCliBinary();
await runCli(
[
'--agent-enqueue',
'--init',
projectRoot,
agentId,
runId,
'这是只读验收。不要修改文件、不要执行命令、不要预览或生成资产;请仔细分析后给出一句简短结论。',
],
null,
120_000,
);
const initial = await waitForRuntime(
(runtime) => runtime.state.runId === runId && isSteerable(runtime.state),
60_000,
);
const sessionId = initial.state.sessionId;
assert(sessionId, 'missing-session-id');
let latestSteerId = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const steerId = `steer-real-${attempt}-${randomUUID().slice(0, 8)}`;
const result = await runCli(
[
'--agent-steer',
projectRoot,
agentId,
sessionId,
runId,
steerId,
'--stdin',
],
instruction,
120_000,
);
assert(!result.stdout.includes(instruction), 'steer-body-stdout-leak');
const steer = parsePrefixedJson(result.stdout, 'steerJson=');
evidence.steerAttempts = attempt;
latestSteerId = steerId;
evidence.providerInterrupted ||= steer.providerInterrupted === true;
if (evidence.providerInterrupted) break;
await waitForRuntime(
(runtime) =>
runtime.state.runId === runId &&
Number(runtime.state.appliedSteerCursor ?? 0) >= attempt &&
isSteerable(runtime.state),
60_000,
);
}
assert(latestSteerId, 'steer-not-accepted');
assert(evidence.providerInterrupted, 'provider-not-interrupted');
const terminal = await waitForRuntime(
(runtime) =>
runtime.recentTasks?.some(
(task) => task.runId === runId && isTerminalTask(task),
) === true,
timeoutMs,
);
const terminalTask = terminal.recentTasks.find(
(task) => task.runId === runId && isTerminalTask(task),
);
assert(terminalTask?.status === 'completed', 'steered-run-not-completed');
const taskRecords = await readJsonl(
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
);
evidence.taskRunIds = [...new Set(taskRecords.map((record) => record.runId))];
assert(
evidence.taskRunIds.length === 1 && evidence.taskRunIds[0] === runId,
'steer-created-new-run',
);
assert(
!taskRecords.some((record) => JSON.stringify(record).includes(instruction)),
'steer-body-task-leak',
);
const steerRecords = await readJsonl(
path.join(projectRoot, '.agent/runtime/steers', agentId, `${runId}.jsonl`),
);
evidence.steerStatuses = steerRecords.map((record) => record.status);
assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing');
assert(evidence.steerStatuses.includes('queued'), 'steer-queued-missing');
assert(evidence.steerStatuses.includes('applied'), 'steer-applied-missing');
assert(evidence.steerStatuses.at(-1) === 'closed', 'steer-ledger-not-closed');
assert(
steerRecords
.filter((record) => record.status === 'prepared')
.every((record) => typeof record.instruction === 'string'),
'steer-prepared-body-missing',
);
assert(
steerRecords
.filter((record) => record.status !== 'prepared')
.every((record) => record.instruction == null),
'steer-body-transition-leak',
);
const conversationPath =
sessionId === `agent-session-${agentId}`
? path.join(
projectRoot,
'.agent/conversations/agents',
`${agentId}.jsonl`,
)
: path.join(
projectRoot,
'.agent/conversations/agents',
agentId,
'sessions',
`${sessionId}.jsonl`,
);
const messages = await readJsonl(conversationPath);
evidence.userMessageCount = messages.filter(
(message) => message.role === 'user',
).length;
evidence.assistantMessageCount = messages.filter(
(message) => message.role === 'assistant',
).length;
assert(
evidence.userMessageCount === evidence.steerAttempts + 1,
'steer-user-message-count-invalid',
);
assert(evidence.assistantMessageCount === 1, 'assistant-count-invalid');
const assistant = messages.find((message) => message.role === 'assistant');
assert(assistant?.content.includes(marker), 'final-assistant-missing-marker');
const publicPaths = [
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
path.join(projectRoot, '.agent/runtime/events', `${agentId}.jsonl`),
path.join(projectRoot, '.agent/runtime/agents', `${agentId}.json`),
path.join(projectRoot, '.agent/agent.db'),
];
evidence.publicInstructionLeakCount = await countNeedleInFiles(
publicPaths,
instruction,
);
assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak');
evidence.loadedKeyLeakCount = await countNeedlesInTree(projectRoot, secrets);
assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak');
status = 'PASS';
} catch (caught) {
error = caught instanceof Error ? caught.message : String(caught);
if (error === 'llm-not-configured') status = 'BLOCKED';
} finally {
if (!options.keepProject) {
await fs.rm(projectRoot, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify(
{
status,
suite: 'steer',
runId,
projectKept: options.keepProject,
evidence,
error,
},
null,
2,
)}\n`,
);
process.exitCode = status === 'PASS' ? 0 : status === 'BLOCKED' ? 2 : 1;
}
function parseArguments(args) {
let configDir = null;
let keepProject = false;
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--config-dir') {
configDir = args[++index];
} else if (args[index] === '--keep-project') {
keepProject = true;
} else {
throw new Error(`unknown-argument:${args[index]}`);
}
}
assert(configDir && path.isAbsolute(configDir), 'config-dir-not-absolute');
const resolved = path.resolve(configDir);
assert(!isInside(repoRoot, resolved), 'config-dir-inside-repository');
return { configDir: resolved, keepProject };
}
async function loadConfig(configDir) {
const content = await fs.readFile(
path.join(configDir, 'game-creator.config.json'),
'utf8',
);
return JSON.parse(content);
}
function isAgentConfigured(config, targetAgentId) {
const effective = {
apiKey: config.llm?.apiKey,
baseUrl: config.llm?.baseUrl,
model: config.llm?.model,
...(config.agentLlm?.[targetAgentId] ?? {}),
};
return ['apiKey', 'baseUrl', 'model'].every(
(key) => typeof effective[key] === 'string' && effective[key].trim(),
);
}
function collectSecrets(config) {
const secrets = [];
for (const candidate of [
config.llm?.apiKey,
config.editorApi?.apiKey,
...Object.values(config.agentLlm ?? {}).map((agent) => agent?.apiKey),
]) {
if (typeof candidate === 'string' && candidate.trim().length >= 8) {
secrets.push(candidate.trim());
}
}
return [...new Set(secrets)];
}
async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
cargo,
['build', '--quiet', '--manifest-path', manifestPath],
null,
15 * 60 * 1000,
);
const metadata = await runProcess(
cargo,
[
'metadata',
'--format-version',
'1',
'--no-deps',
'--manifest-path',
manifestPath,
],
null,
120_000,
);
const parsed = JSON.parse(metadata.stdout);
return path.join(
parsed.target_directory,
'debug',
`genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`,
);
}
async function runCli(args, input = null, commandTimeoutMs = 60_000) {
return runProcess(
binary,
[...args, '--config-dir', options.configDir],
input,
commandTimeoutMs,
);
}
async function waitForRuntime(predicate, waitMs) {
const deadline = Date.now() + waitMs;
let latest = null;
while (Date.now() < deadline) {
const result = await runCli([
'--agent-runtime-status',
projectRoot,
agentId,
]);
latest = parsePrefixedJson(result.stdout, 'runtimeJson=');
if (predicate(latest)) return latest;
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(
`runtime-timeout:${latest?.state?.status ?? 'missing'}/${latest?.state?.phase ?? 'missing'}`,
);
}
function isSteerable(runtime) {
return (
['running', 'waiting-for-confirmation'].includes(runtime.status) &&
!['cancelling', 'finalizing', 'needs-reconciliation'].includes(
runtime.phase,
)
);
}
function isTerminalTask(task) {
return ['completed', 'failed', 'cancelled'].includes(task.status);
}
function parsePrefixedJson(stdout, prefix) {
const line = stdout
.split(/\r?\n/u)
.find((candidate) => candidate.startsWith(prefix));
assert(line, `missing-output:${prefix}`);
return JSON.parse(line.slice(prefix.length));
}
async function readJsonl(file) {
const content = await fs.readFile(file, 'utf8');
return content
.split(/\r?\n/u)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
}
async function countNeedleInFiles(files, needle) {
let count = 0;
for (const file of files) {
const content = await fs.readFile(file).catch(() => Buffer.alloc(0));
count += countNeedle(content, Buffer.from(needle));
}
return count;
}
async function countNeedlesInTree(root, needles) {
let count = 0;
const entries = await fs.readdir(root, { withFileTypes: true });
for (const entry of entries) {
const target = path.join(root, entry.name);
if (entry.isDirectory()) {
count += await countNeedlesInTree(target, needles);
} else if (entry.isFile()) {
const content = await fs.readFile(target);
for (const needle of needles)
count += countNeedle(content, Buffer.from(needle));
}
}
return count;
}
function countNeedle(haystack, needle) {
if (needle.length === 0) return 0;
let count = 0;
let offset = 0;
while (offset <= haystack.length - needle.length) {
const index = haystack.indexOf(needle, offset);
if (index < 0) break;
count += 1;
offset = index + needle.length;
}
return count;
}
function runProcess(program, args, input, commandTimeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
cwd: appRoot,
env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, commandTimeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString('utf8');
});
child.on('error', reject);
child.on('close', (code, signal) => {
clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`command-failed:${code ?? signal}:${stderr.trim()}`));
}
});
if (input === null) child.stdin.end();
else child.stdin.end(input);
});
}
function isInside(parent, target) {
const relative = path.relative(parent, target);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
function assert(condition, code) {
if (!condition) throw new Error(code);
}