From 917fcd57265df73b6a16aeb88e9743b4923cc063 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 23 Jul 2026 12:15:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0Agent=20Swarm=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=B5=8B=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agc:test 确定性自动验收短命令。 新增 agc:test:chat 自动发现 AppData、创建测试项目并启动自主 Swarm。 复用正式预览服务完成浏览器打开、信号停止和安全清理。 补充命令契约、跨平台目录、项目哨兵和预览地址测试。 同步客户端技术方案与团队复验流程。 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-swarm-test-chat.mjs | 552 ++++++++++++++++++ .../scripts/check-config.mjs | 41 ++ .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/cli.rs | 91 +++ .../tests/agentSwarmTestEntry.test.ts | 380 ++++++++++++ .../shared-memory/development-workflow.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + package.json | 2 + 9 files changed, 1079 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs create mode 100644 apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index ee9c475f1..fee5c9adf 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -12,6 +12,7 @@ "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "chat": "node scripts/run-cli-with-config.mjs --swarm-chat", "swarm": "node scripts/run-cli-with-config.mjs --swarm-chat", + "test:chat": "node scripts/agent-swarm-test-chat.mjs", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs new file mode 100644 index 000000000..55625dfc8 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -0,0 +1,552 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const appIdentifier = 'world.genarrative.ai-game-creator'; +export const configFileName = 'game-creator.config.json'; +export const testProjectPrefix = 'genarrative-agc-swarm-test-'; +export const testProjectSentinelName = '.agc-swarm-test.json'; +export const testProjectSentinelSchema = + 'genarrative-agc-swarm-test-project.v1'; + +const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); +const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + +export const usage = `用法:npm run agc:test:chat -- [选项] + +自动读取客户端 AppData 配置、创建一次性项目并进入 Project Supervisor 自主测试。 +Swarm 完成后启动本地试玩;按 Ctrl+C 停止预览并清理一次性项目。 + +选项: + --config-dir <绝对路径> 显式指定客户端 AppData 目录 + --project-dir <绝对路径> 使用已有项目或空目录,不自动删除 + --keep-project 保留自动创建的一次性项目 + --no-open 启动预览但不自动打开浏览器 + --dry-run 只检查目录发现和项目准备,不启动 LLM + -h, --help 显示帮助`; + +function readOptionValue(args, index, option) { + const value = args[index + 1]?.trim(); + if (!value || value.startsWith('--')) { + throw new Error(`${option} 缺少路径`); + } + return value; +} + +export function parseSwarmTestArguments(args) { + const options = { + configDir: null, + projectDir: null, + keepProject: false, + openBrowser: true, + dryRun: false, + help: false, + }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--config-dir') { + if (options.configDir) throw new Error('--config-dir 只能指定一次'); + options.configDir = readOptionValue(args, index, argument); + index += 1; + } else if (argument === '--project-dir') { + if (options.projectDir) throw new Error('--project-dir 只能指定一次'); + options.projectDir = readOptionValue(args, index, argument); + index += 1; + } else if (argument === '--keep-project') { + options.keepProject = true; + } else if (argument === '--no-open') { + options.openBrowser = false; + } else if (argument === '--dry-run') { + options.dryRun = true; + } else if (argument === '--help' || argument === '-h') { + options.help = true; + } else { + throw new Error(`未知选项:${argument}`); + } + } + return options; +} + +function pushUnique(values, value) { + if (value && !values.includes(value)) values.push(value); +} + +export function defaultRuntimeConfigDirCandidates({ + platform = process.platform, + environment = process.env, + homeDirectory = os.homedir(), +} = {}) { + const candidates = []; + if (platform === 'win32') { + pushUnique( + candidates, + environment.APPDATA + ? path.win32.join(environment.APPDATA, appIdentifier) + : path.win32.join(homeDirectory, 'AppData', 'Roaming', appIdentifier), + ); + if (environment.LOCALAPPDATA) { + pushUnique( + candidates, + path.win32.join(environment.LOCALAPPDATA, appIdentifier), + ); + } + } else if (platform === 'darwin') { + pushUnique( + candidates, + path.posix.join( + homeDirectory, + 'Library', + 'Application Support', + appIdentifier, + ), + ); + } else { + const configRoot = + environment.XDG_CONFIG_HOME && + path.posix.isAbsolute(environment.XDG_CONFIG_HOME) + ? environment.XDG_CONFIG_HOME + : path.posix.join(homeDirectory, '.config'); + pushUnique(candidates, path.posix.join(configRoot, appIdentifier)); + } + return candidates; +} + +async function isRegularFileWithoutSymlink(filePath) { + const metadata = await lstat(filePath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + return Boolean(metadata?.isFile() && !metadata.isSymbolicLink()); +} + +export async function discoverRuntimeConfigDir( + explicitConfigDir, + platformContext, +) { + if (explicitConfigDir && !path.isAbsolute(explicitConfigDir)) { + throw new Error('--config-dir 必须是绝对路径'); + } + const candidates = explicitConfigDir + ? [explicitConfigDir] + : defaultRuntimeConfigDirCandidates(platformContext); + for (const candidate of candidates) { + const resolved = path.resolve(candidate); + const metadata = await lstat(resolved).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata?.isDirectory() || metadata.isSymbolicLink()) continue; + if ( + !(await isRegularFileWithoutSymlink(path.join(resolved, configFileName))) + ) { + continue; + } + return realpath(resolved); + } + if (explicitConfigDir) { + throw new Error( + `指定目录中没有可用的 ${configFileName}:${explicitConfigDir}`, + ); + } + throw new Error( + `未找到客户端 AppData 配置。请先运行 npm run agc,在“运行时配置”中保存 LLM Provider,或传入 --config-dir。`, + ); +} + +async function ensureExplicitProject(projectDir) { + if (!path.isAbsolute(projectDir)) { + throw new Error('--project-dir 必须是绝对路径'); + } + const resolved = path.resolve(projectDir); + const metadata = await lstat(resolved).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) { + await mkdir(resolved, { recursive: true, mode: 0o700 }); + } else if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`测试项目路径必须是普通目录:${resolved}`); + } + const canonical = await realpath(resolved); + const entries = await readdir(canonical); + const initialized = await isRegularFileWithoutSymlink( + path.join(canonical, '.agent', 'manifest.json'), + ); + if (entries.length > 0 && !initialized) { + throw new Error(`--project-dir 只能指向空目录或已初始化项目:${canonical}`); + } + return { + path: canonical, + owned: false, + sentinelToken: null, + }; +} + +export async function prepareSwarmTestProject(explicitProjectDir, tempRoot) { + if (explicitProjectDir) return ensureExplicitProject(explicitProjectDir); + const root = path.resolve(tempRoot ?? os.tmpdir()); + const projectPath = await mkdtemp(path.join(root, testProjectPrefix)); + try { + if (process.platform !== 'win32') await chmod(projectPath, 0o700); + const sentinelToken = randomUUID(); + await writeFile( + path.join(projectPath, testProjectSentinelName), + `${JSON.stringify({ + schemaVersion: testProjectSentinelSchema, + token: sentinelToken, + })}\n`, + { flag: 'wx', mode: 0o600 }, + ); + return { + path: await realpath(projectPath), + owned: true, + sentinelToken, + }; + } catch (error) { + await rm(projectPath, { recursive: true, force: true }); + throw error; + } +} + +export async function cleanupSwarmTestProject(project) { + if (!project?.owned) return false; + const sentinelPath = path.join(project.path, testProjectSentinelName); + if (!(await isRegularFileWithoutSymlink(sentinelPath))) { + throw new Error('拒绝清理:一次性项目哨兵缺失或类型无效'); + } + let sentinel; + try { + sentinel = JSON.parse(await readFile(sentinelPath, 'utf8')); + } catch (error) { + throw new Error(`拒绝清理:一次性项目哨兵无效:${error.message}`); + } + if ( + sentinel.schemaVersion !== testProjectSentinelSchema || + sentinel.token !== project.sentinelToken + ) { + throw new Error('拒绝清理:一次性项目哨兵身份不匹配'); + } + const canonical = await realpath(project.path); + if ( + canonical !== project.path || + !path.basename(canonical).startsWith(testProjectPrefix) + ) { + throw new Error('拒绝清理:一次性项目目录身份不匹配'); + } + await rm(canonical, { recursive: true, force: false }); + return true; +} + +export function buildCargoCliArguments(cliArguments) { + return ['run', '--manifest-path', cargoManifestPath, '--', ...cliArguments]; +} + +function spawnChild(command, args, options = {}) { + return spawn(command, args, { + cwd: appRoot, + env: process.env, + ...options, + }); +} + +function childExit(child) { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }); +} + +async function runCapturedCargo(cliArguments, setActiveChild) { + const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { + stdio: ['ignore', 'pipe', 'pipe'], + }); + setActiveChild(child); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + const result = await childExit(child); + setActiveChild(null); + return { ...result, stdout, stderr }; +} + +async function runInteractiveCargo(cliArguments, setActiveChild) { + const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { + stdio: 'inherit', + }); + setActiveChild(child); + const result = await childExit(child); + setActiveChild(null); + return result; +} + +export function validatePreviewUrl(value) { + const url = new URL(value); + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + !url.port || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('预览命令返回了非 loopback URL'); + } + return url.toString(); +} + +export async function openPreviewUrl(url, platform = process.platform) { + const validated = validatePreviewUrl(url); + const command = + platform === 'win32' + ? 'cmd.exe' + : platform === 'darwin' + ? 'open' + : 'xdg-open'; + const args = + platform === 'win32' + ? ['/d', '/s', '/c', 'start', '', validated] + : [validated]; + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} + +async function runPreview( + projectPath, + openBrowser, + setActiveChild, + stopRequested, +) { + const child = spawnChild( + cargoCommand, + buildCargoCliArguments(['--preview-serve', projectPath]), + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + setActiveChild(child); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + + let pending = ''; + let previewUrl = null; + let resolvePreviewUrl; + let rejectPreviewUrl; + const previewUrlReady = new Promise((resolve, reject) => { + resolvePreviewUrl = resolve; + rejectPreviewUrl = reject; + }); + child.stdout.on('data', (chunk) => { + pending += chunk; + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith('previewUrl=')) continue; + try { + previewUrl = validatePreviewUrl( + line.slice('previewUrl='.length).trim(), + ); + resolvePreviewUrl(previewUrl); + } catch (error) { + rejectPreviewUrl(error); + } + } + }); + + const exitPromise = childExit(child); + try { + exitPromise.then(({ code, signal }) => { + if (!previewUrl) { + rejectPreviewUrl( + new Error( + `预览进程提前退出:code=${code ?? ''} signal=${signal ?? ''}`, + ), + ); + } + }); + const url = await previewUrlReady; + console.log(`\n试玩地址:${url}`); + console.log('按 Ctrl+C 结束预览。'); + if (openBrowser) { + await openPreviewUrl(url).catch((error) => { + console.warn( + `无法自动打开浏览器,请手动访问上面的地址:${error.message}`, + ); + }); + } + const result = await exitPromise; + if (!stopRequested() && (result.code !== 0 || result.signal)) { + throw new Error( + `预览进程异常退出:code=${result.code ?? ''} signal=${result.signal ?? ''}`, + ); + } + } finally { + if (child.exitCode === null && child.signalCode === null && !child.killed) { + try { + child.kill('SIGINT'); + } catch { + child.kill(); + } + await exitPromise.catch(() => {}); + } + setActiveChild(null); + } +} + +export async function runSwarmTestChat(options) { + const configDir = await discoverRuntimeConfigDir(options.configDir); + const project = await prepareSwarmTestProject(options.projectDir); + let activeChild = null; + let receivedSignal = null; + let phase = 'setup'; + const setActiveChild = (child) => { + activeChild = child; + }; + const stopRequested = () => receivedSignal !== null; + const handleSignal = (signal) => { + receivedSignal ??= signal; + if ( + !activeChild || + activeChild.exitCode !== null || + activeChild.signalCode !== null + ) { + return; + } + try { + activeChild.kill(signal); + } catch { + activeChild.kill(); + } + }; + process.on('SIGINT', handleSignal); + process.on('SIGTERM', handleSignal); + + try { + console.log(`配置:${path.join(configDir, configFileName)}`); + console.log(`测试项目:${project.path}`); + if (options.dryRun) { + console.log('测试环境检查通过;未启动 LLM。'); + return; + } + + console.log('\n正在检查 LLM 配置...'); + const llmStatus = await runCapturedCargo( + ['--config-dir', configDir, '--llm-status'], + setActiveChild, + ); + if (receivedSignal) return; + if (llmStatus.code !== 0 || llmStatus.signal) { + throw new Error( + `LLM 配置未就绪:${llmStatus.stderr.trim() || llmStatus.stdout.trim()}`, + ); + } + console.log('LLM 配置已就绪。'); + console.log( + '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', + ); + + phase = 'chat'; + const chat = await runInteractiveCargo( + [ + '--config-dir', + configDir, + '--swarm-chat', + '--init', + '--autonomous-game-build', + project.path, + ], + setActiveChild, + ); + if (receivedSignal) return; + if (chat.code !== 0 || chat.signal) { + throw new Error( + `Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`, + ); + } + phase = 'preview'; + if ( + !(await isRegularFileWithoutSymlink( + path.join(project.path, 'game', 'index.html'), + )) + ) { + throw new Error('Agent Swarm 已退出,但未生成 game/index.html'); + } + + console.log('\nAgent Swarm 已收束,正在启动试玩...'); + await runPreview( + project.path, + options.openBrowser, + setActiveChild, + stopRequested, + ); + phase = 'complete'; + } finally { + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + const preserveFailedRun = + project.owned && + (phase === 'chat' || (phase === 'preview' && !receivedSignal)); + if (project.owned && (options.keepProject || preserveFailedRun)) { + if (preserveFailedRun && !options.keepProject) { + console.warn( + '测试尚未正常结束,为避免删除后台任务或失败证据,测试项目不会自动清理。', + ); + } + console.log(`已保留测试项目:${project.path}`); + } else if (project.owned) { + await cleanupSwarmTestProject(project); + console.log('已清理一次性测试项目。'); + } + } +} + +async function main() { + const options = parseSwarmTestArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage); + return; + } + await runSwarmTestChat(options); +} + +const entryPath = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (entryPath === import.meta.url) { + main().catch((error) => { + console.error(`agc:test:chat 失败:${error.message}`); + process.exitCode = 1; + }); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c6b29256e..576e2e324 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -22,6 +22,10 @@ const defaultAppConfig = JSON.parse( const rootPackageConfig = JSON.parse( fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), ); +const swarmTestChatSource = fs.readFileSync( + new URL('../scripts/agent-swarm-test-chat.mjs', import.meta.url), + 'utf8', +); const viteConfigSource = fs.readFileSync( new URL('../vite.config.ts', import.meta.url), 'utf8', @@ -401,6 +405,43 @@ if ( ); } +if ( + packageConfig.scripts?.['test:chat'] !== + 'node scripts/agent-swarm-test-chat.mjs' +) { + throw new Error( + 'AI game creator shell test:chat must use the one-click Swarm test entry', + ); +} + +if ( + rootPackageConfig.scripts?.['agc:test'] !== + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --' +) { + throw new Error('agc:test must delegate to the deterministic playable E2E'); +} + +if ( + rootPackageConfig.scripts?.['agc:test:chat'] !== + 'npm --prefix apps/ai-game-creator-shell run test:chat --' +) { + throw new Error( + 'agc:test:chat must delegate to the one-click Swarm test entry', + ); +} + +for (const requiredSource of [ + "export const appIdentifier = 'world.genarrative.ai-game-creator'", + "'--swarm-chat'", + "'--autonomous-game-build'", + "'--preview-serve'", + 'cleanupSwarmTestProject(project)', +]) { + if (!swarmTestChatSource.includes(requiredSource)) { + throw new Error(`Swarm test entry contract drifted: ${requiredSource}`); + } +} + if (tauriConfig.productName !== 'Genarrative AI Game Creator') { throw new Error('AI game creator shell productName drifted'); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 1b21a1d5e..47ef56504 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -27,7 +27,7 @@ tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2" tempfile = "3" -tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } url = "2" unicode-normalization = "0.1" zip = { version = "2", default-features = false, features = ["deflate"] } 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 8601c9ea5..919c7f23a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -1,8 +1,13 @@ use super::*; +const PREVIEW_SERVE_USAGE: &str = "用法:--preview-serve <本地项目绝对路径>"; + #[derive(Debug, Eq, PartialEq)] pub(crate) enum CliCommand { LlmStatus, + PreviewServe { + project_path: PathBuf, + }, AgentChat { project_path: PathBuf, agent_id: String, @@ -186,6 +191,7 @@ impl CliCommand { | Self::AgentRetry { project_path, .. } | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } + | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), Self::LlmStatus | Self::RunnerStatus => None, } @@ -413,6 +419,14 @@ fn parse_cli_agent_goal_revision(value: &str, usage: &str) -> Result Result, String> { + if args.first().map(String::as_str) == Some("--preview-serve") { + if args.len() != 2 || args[1].trim().is_empty() { + return Err(PREVIEW_SERVE_USAGE.to_string()); + } + return Ok(Some(CliCommand::PreviewServe { + project_path: PathBuf::from(&args[1]), + })); + } if args.first().map(String::as_str) == Some("--llm-status") { return Ok(Some(CliCommand::LlmStatus)); } @@ -785,6 +799,35 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { Err("LLM 配置未就绪".to_string()) } } + CliCommand::PreviewServe { project_path } => { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; + let registry = game_creator_preview_registry(); + let preview = start_local_game_preview_at(&project_path, ®istry)?; + println!("preview.running"); + println!("projectPath={}", project_path.display()); + println!("previewUrl={}", preview.url); + let wait_result = std::io::stdout() + .flush() + .map_err(|error| format!("刷新本地预览 CLI 输出失败:{error}")) + .and_then(|()| { + runtime + .block_on(tokio::signal::ctrl_c()) + .map_err(|error| format!("等待 Ctrl+C 失败:{error}")) + }); + let stop_result = stop_local_game_preview_for_root(Some(&project_path), ®istry) + .map(|_| ()) + .map_err(|error| format!("停止本地预览失败:{error}")); + match (wait_result, stop_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(wait_error), Err(stop_error)) => { + Err(format!("{wait_error};同时{stop_error}")) + } + } + } CliCommand::AgentChat { project_path, agent_id, @@ -1329,6 +1372,54 @@ mod tests { assert_eq!(parsed["runtimes"][0]["state"]["status"], "running"); } + #[test] + fn parses_preview_serve_without_runner_or_config() { + let project_path = std::env::current_dir().expect("current directory"); + let mut command = parse_cli_command(&[ + "--preview-serve".to_string(), + project_path.display().to_string(), + ]) + .expect("parse preview serve") + .expect("preview serve command"); + + assert_eq!( + command, + CliCommand::PreviewServe { + project_path: project_path.clone(), + } + ); + assert!(!command.requires_external_agent_runner()); + assert!(!command.requires_started_external_agent_runner()); + assert!(!command.is_read_only_status()); + assert_eq!( + prepare_cli_command_paths(&mut command, None) + .expect("prepare preview serve without config dir"), + None + ); + assert_eq!( + command, + CliCommand::PreviewServe { + project_path: fs::canonicalize(project_path).expect("canonical project path"), + } + ); + } + + #[test] + fn preview_serve_requires_exactly_one_non_empty_project_path() { + for args in [ + vec!["--preview-serve"], + vec!["--preview-serve", " "], + vec!["--preview-serve", "/tmp/game-project", "/tmp/extra"], + ] { + let args = args.into_iter().map(str::to_string).collect::>(); + assert_eq!( + parse_cli_command(&args), + Err(PREVIEW_SERVE_USAGE.to_string()), + "unexpected parse result for {args:?}" + ); + } + } + #[test] fn parses_agent_steer_with_stdin_only_contract() { let command = parse_cli_command(&[ diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts new file mode 100644 index 000000000..2150c548b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -0,0 +1,380 @@ +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + appIdentifier, + buildCargoCliArguments, + cleanupSwarmTestProject, + configFileName, + defaultRuntimeConfigDirCandidates, + discoverRuntimeConfigDir, + parseSwarmTestArguments, + prepareSwarmTestProject, + testProjectPrefix, + testProjectSentinelName, + testProjectSentinelSchema, + validatePreviewUrl, +} from '../scripts/agent-swarm-test-chat.mjs'; + +const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); + +async function withTemporaryRoot( + run: (root: string) => Promise, +): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), 'genarrative-swarm-entry-test-'), + ); + try { + return await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function pathExists(targetPath: string): Promise { + return lstat(targetPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false; + throw error; + }, + ); +} + +describe('Swarm test argument parsing', () => { + it('returns the documented defaults', () => { + expect(parseSwarmTestArguments([])).toEqual({ + configDir: null, + projectDir: null, + keepProject: false, + openBrowser: true, + dryRun: false, + help: false, + }); + }); + + it('parses every supported option', () => { + const configDir = path.resolve('fixture-config'); + const projectDir = path.resolve('fixture-project'); + + expect( + parseSwarmTestArguments([ + '--config-dir', + configDir, + '--project-dir', + projectDir, + '--keep-project', + '--no-open', + '--dry-run', + '--help', + ]), + ).toEqual({ + configDir, + projectDir, + keepProject: true, + openBrowser: false, + dryRun: true, + help: true, + }); + expect(parseSwarmTestArguments(['-h']).help).toBe(true); + }); + + it.each([ + { + label: 'duplicate config directory', + args: ['--config-dir', '/first', '--config-dir', '/second'], + marker: '--config-dir', + }, + { + label: 'duplicate project directory', + args: ['--project-dir', '/first', '--project-dir', '/second'], + marker: '--project-dir', + }, + { + label: 'missing config directory', + args: ['--config-dir'], + marker: '--config-dir', + }, + { + label: 'missing project directory', + args: ['--project-dir', '--dry-run'], + marker: '--project-dir', + }, + { + label: 'unknown option', + args: ['--unsupported'], + marker: '--unsupported', + }, + ])('rejects $label', ({ args, marker }) => { + expect(() => parseSwarmTestArguments(args)).toThrow(marker); + }); +}); + +describe('runtime config directory candidates', () => { + it('uses an absolute Linux XDG config root', () => { + expect( + defaultRuntimeConfigDirCandidates({ + platform: 'linux', + environment: { XDG_CONFIG_HOME: '/fixture/xdg' }, + homeDirectory: '/fixture/home', + }), + ).toEqual([path.posix.join('/fixture/xdg', appIdentifier)]); + }); + + it('falls back to the Linux home config root', () => { + expect( + defaultRuntimeConfigDirCandidates({ + platform: 'linux', + environment: { XDG_CONFIG_HOME: 'relative-xdg' }, + homeDirectory: '/fixture/home', + }), + ).toEqual([path.posix.join('/fixture/home', '.config', appIdentifier)]); + }); + + it('uses the macOS Application Support directory', () => { + expect( + defaultRuntimeConfigDirCandidates({ + platform: 'darwin', + environment: {}, + homeDirectory: '/Users/fixture', + }), + ).toEqual([ + path.posix.join( + '/Users/fixture', + 'Library', + 'Application Support', + appIdentifier, + ), + ]); + }); + + it('uses both Windows roaming and local AppData directories', () => { + const appData = 'C:\\Users\\fixture\\AppData\\Roaming'; + const localAppData = 'C:\\Users\\fixture\\AppData\\Local'; + + expect( + defaultRuntimeConfigDirCandidates({ + platform: 'win32', + environment: { + APPDATA: appData, + LOCALAPPDATA: localAppData, + }, + homeDirectory: 'C:\\Users\\fixture', + }), + ).toEqual([ + path.win32.join(appData, appIdentifier), + path.win32.join(localAppData, appIdentifier), + ]); + }); +}); + +describe('runtime config discovery', () => { + it('discovers an explicitly selected config directory', async () => { + await withTemporaryRoot(async (root) => { + const configDir = path.join(root, 'explicit-config'); + await mkdir(configDir); + await writeFile(path.join(configDir, configFileName), '{}\n'); + + await expect( + discoverRuntimeConfigDir(configDir, { + platform: 'linux', + environment: { XDG_CONFIG_HOME: path.join(root, 'unused') }, + homeDirectory: path.join(root, 'unused-home'), + }), + ).resolves.toBe(await realpath(configDir)); + }); + }); + + it('discovers a config directory from an isolated XDG root', async () => { + await withTemporaryRoot(async (root) => { + const xdgRoot = path.join(root, 'xdg'); + const configDir = path.join(xdgRoot, appIdentifier); + await mkdir(configDir, { recursive: true }); + await writeFile(path.join(configDir, configFileName), '{}\n'); + + await expect( + discoverRuntimeConfigDir(null, { + platform: 'linux', + environment: { XDG_CONFIG_HOME: xdgRoot }, + homeDirectory: path.join(root, 'unused-home'), + }), + ).resolves.toBe(await realpath(configDir)); + }); + }); + + it('rejects symlinked and non-file config entries', async () => { + await withTemporaryRoot(async (root) => { + const target = path.join(root, 'config-target.json'); + await writeFile(target, '{}\n'); + + const symlinkConfigDir = path.join(root, 'symlink-config'); + await mkdir(symlinkConfigDir); + await symlink(target, path.join(symlinkConfigDir, configFileName)); + await expect(discoverRuntimeConfigDir(symlinkConfigDir)).rejects.toThrow( + configFileName, + ); + + const directoryConfigDir = path.join(root, 'directory-config'); + await mkdir(path.join(directoryConfigDir, configFileName), { + recursive: true, + }); + await expect( + discoverRuntimeConfigDir(directoryConfigDir), + ).rejects.toThrow(configFileName); + }); + }); + + it('rejects a relative explicit config directory', async () => { + await expect(discoverRuntimeConfigDir('relative-config')).rejects.toThrow( + '--config-dir', + ); + }); +}); + +describe('Swarm test project ownership', () => { + it('creates a sentinel-owned project and removes it during cleanup', async () => { + await withTemporaryRoot(async (root) => { + const project = await prepareSwarmTestProject(null, root); + const sentinelPath = path.join(project.path, testProjectSentinelName); + const sentinelMetadata = await lstat(sentinelPath); + const sentinel = JSON.parse(await readFile(sentinelPath, 'utf8')); + + expect(project.owned).toBe(true); + expect(project.sentinelToken).toEqual(expect.any(String)); + expect(path.basename(project.path)).toMatch( + new RegExp(`^${testProjectPrefix}`), + ); + expect(sentinelMetadata.isFile()).toBe(true); + expect(sentinelMetadata.isSymbolicLink()).toBe(false); + expect(sentinel).toEqual({ + schemaVersion: testProjectSentinelSchema, + token: project.sentinelToken, + }); + + await expect(cleanupSwarmTestProject(project)).resolves.toBe(true); + expect(await pathExists(project.path)).toBe(false); + }); + }); + + it('refuses cleanup after the sentinel identity is changed', async () => { + await withTemporaryRoot(async (root) => { + const project = await prepareSwarmTestProject(null, root); + await writeFile( + path.join(project.path, testProjectSentinelName), + `${JSON.stringify({ + schemaVersion: testProjectSentinelSchema, + token: 'changed-token', + })}\n`, + ); + + await expect(cleanupSwarmTestProject(project)).rejects.toThrowError(); + expect(await pathExists(project.path)).toBe(true); + }); + }); + + it('keeps an explicit empty directory unowned', async () => { + await withTemporaryRoot(async (root) => { + const explicitProjectDir = path.join(root, 'explicit-project'); + await mkdir(explicitProjectDir); + + const project = await prepareSwarmTestProject(explicitProjectDir, root); + + expect(project).toEqual({ + path: await realpath(explicitProjectDir), + owned: false, + sentinelToken: null, + }); + await expect(cleanupSwarmTestProject(project)).resolves.toBe(false); + expect(await readdir(explicitProjectDir)).toEqual([]); + }); + }); + + it('rejects a non-empty uninitialized explicit directory', async () => { + await withTemporaryRoot(async (root) => { + const explicitProjectDir = path.join(root, 'uninitialized-project'); + await mkdir(explicitProjectDir); + await writeFile(path.join(explicitProjectDir, 'existing.txt'), 'fixture'); + + await expect( + prepareSwarmTestProject(explicitProjectDir, root), + ).rejects.toThrow('--project-dir'); + }); + }); +}); + +describe('cargo CLI argument construction', () => { + it('uses the shell manifest and separates cargo from application arguments', () => { + const cliArguments = ['--config-dir', 'fixture-config', '--llm-status']; + const cargoArguments = buildCargoCliArguments(cliArguments); + + expect(cargoArguments.slice(0, 2)).toEqual(['run', '--manifest-path']); + expect(path.isAbsolute(cargoArguments[2])).toBe(true); + expect(path.relative(appRoot, cargoArguments[2])).toBe( + path.join('src-tauri', 'Cargo.toml'), + ); + expect(cargoArguments[3]).toBe('--'); + expect(cargoArguments.slice(4)).toEqual(cliArguments); + }); +}); + +describe('preview URL validation', () => { + it('accepts an HTTP URL on the numeric loopback host', () => { + const previewUrl = 'http://127.0.0.1:4173/'; + + expect(validatePreviewUrl(previewUrl)).toBe(previewUrl); + }); + + it.each([ + 'https://127.0.0.1:4173/', + 'http://localhost:4173/', + 'http://[::1]:4173/', + 'http://192.0.2.1:4173/', + 'http://127.0.0.1.example:4173/', + 'http://127.0.0.1/', + 'http://127.0.0.1:4173/play', + 'http://127.0.0.1:4173/?mode=test', + 'http://127.0.0.1:4173/#ready', + 'http://user@127.0.0.1:4173/', + 'file:///fixture/game/index.html', + 'not-a-url', + ])('rejects %s', (previewUrl) => { + expect(() => validatePreviewUrl(previewUrl)).toThrowError(); + }); +}); + +describe('package script registration', () => { + it('registers the root and app test commands exactly', async () => { + const [rootPackage, appPackage] = await Promise.all( + [ + new URL('../../../package.json', import.meta.url), + new URL('../package.json', import.meta.url), + ].map(async (packageUrl) => + JSON.parse(await readFile(packageUrl, 'utf8')), + ), + ); + + expect(rootPackage.scripts?.['agc:test']).toBe( + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --', + ); + expect(rootPackage.scripts?.['agc:test:chat']).toBe( + 'npm --prefix apps/ai-game-creator-shell run test:chat --', + ); + expect(appPackage.scripts?.['test:chat']).toBe( + 'node scripts/agent-swarm-test-chat.mjs', + ); + }); +}); diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 7c46f6e57..564a1931a 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -161,6 +161,15 @@ suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `060 ### AI 游戏创作自主 Swarm 终端复验 +日常人工验收优先使用短入口,不再手工拼 AppData、临时项目和预览命令: + +```bash +npm run agc:test +npm run agc:test:chat +``` + +`agc:test` 委托确定性 lane-defense E2E,使用本地 loopback Provider 完成 Runtime、项目写入和真实浏览器 `37/37` 门禁,不消耗外部 Provider。`agc:test:chat` 按当前平台自动查找发布客户端 AppData 中的 `game-creator.config.json`,创建 sentinel 管理的一次性项目,进入 `project-supervisor + autonomous-game-build`;用户只输入一条需求并以 EOF 交付,收束后复用正式 localhost preview server 并打开试玩。正常收束后按 `Ctrl+C` 默认清理一次性项目;尚有后台任务或启动预览失败时保留现场,避免误删运行中项目。需要主动保留时显式追加 `-- --keep-project`,需要覆盖配置或项目时使用 `--config-dir` / `--project-dir` 绝对路径。脚本不得读取或打印 API Key,显式项目永不自动删除,非空且未初始化目录必须拒绝。 + 修改 Supervisor 自主编排、`agent.message`、static delivery/claim/repair、Swarm CLI `turn.report`、Runner 恢复或 autonomous harness 后,先跑确定性收敛门禁,再运行真实终端 suite: ```bash diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f4f9dd639..c43a7f944 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -86,6 +86,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独立真实外部轮次已完整 **PASS**:用户只输入一次任务后 stdin 立即 EOF,人工 approve / answer / steer 均为 `0`;一个原始专业任务失败后由唯一 repair 自行恢复,父 Supervisor 为 `idle / completed`,`turn.report=settled` 且只有 `1` 条 `44` 字符 assistant。项目 revision `0 -> 6`,`game/index.html` 为 `8080` bytes 且已变化,两次静态检查通过,desktop / mobile 的 `lane-defense-v1` 真实 Chrome 试玩为 `37/37`。`88` 个 Provider identity 全部 terminal,其中 `75 completed / 13 failed`,`12` 条 durable retry audit 与专业 repair 均自行恢复;open lifecycle、pending、confirmation、user-input、provider batch/retry/handoff/tool-plan handoff、finalization、reconciliation、duplicate 与各类泄漏终局均为 `0`,Runner、disposable 项目和隔离 AppData 已自动清理。该轮证明失败 attempt 可保留真实证据而循环仍能零人工干预收束,不能把它改写成 Provider 零失败。 +2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData 中的 `game-creator.config.json`,创建带私有 sentinel 的一次性项目,先执行不回显密钥的 LLM 状态检查,再以 `--swarm-chat --init --autonomous-game-build` 进入真实 Project Supervisor。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后默认验证 sentinel 并清理项目;尚未收束或预览启动失败时保留现场,避免删除后台任务和失败证据。`--keep-project` 可主动保留,显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` / `--project-dir` 只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 + 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 在通过原身份、revision 和 verification gate 校验后从当前 Runtime state 补齐计划字段继续恢复;计划元数据本身不推进项目 revision、不改变 verification gate,也不触发项目权限确认。开发 UI 和 CLI 有界展示 revision、说明与完整 8 步;正式用户的 Supervisor 只展示完成数、当前步骤、等待对象、下一步和协作数量的紧凑摘要。恢复、same-run steer 和真实 Provider 的完整验收矩阵以 Runtime V1.17 章节为准;2026-07-16 已在当前 v5 context 上完成正式 `openai_chat / gpt-5.5` 的同 run steer + Runner 强杀恢复专项,门禁状态为 PASS。 diff --git a/package.json b/package.json index aee2312a8..ddb9b1d52 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,8 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:check": "npm run ai-game-creator-shell:check", "agc:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", + "agc:test": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --", + "agc:test:chat": "npm --prefix apps/ai-game-creator-shell run test:chat --", "ai-game-creator-shell:dev": "npm --prefix apps/ai-game-creator-shell run dev", "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --",