添加Agent Swarm一键测试入口

新增 agc:test 确定性自动验收短命令。
新增 agc:test:chat 自动发现 AppData、创建测试项目并启动自主 Swarm。
复用正式预览服务完成浏览器打开、信号停止和安全清理。
补充命令契约、跨平台目录、项目哨兵和预览地址测试。
同步客户端技术方案与团队复验流程。
This commit is contained in:
AIGameCreator App
2026-07-23 12:15:26 +08:00
parent 167b11d52e
commit 917fcd5726
9 changed files with 1079 additions and 1 deletions
+1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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');
}
@@ -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"] }
@@ -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<u64, String
}
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, 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, &registry)?;
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), &registry)
.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::<Vec<_>>();
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(&[
@@ -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<T>(
run: (root: string) => Promise<T>,
): Promise<T> {
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<boolean> {
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',
);
});
});
@@ -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
@@ -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。
+2
View File
@@ -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 --",