Merge branch 'master' into codex/dependency-graph
Project CI / Repository checks (pull_request) Successful in 54s
Project CI / Frontend tests (pull_request) Failing after 2m9s
Project CI / Backend tests (pull_request) Successful in 3m46s
Project CI / Native shell tests (pull_request) Failing after 3m4s

This commit is contained in:
2026-08-03 11:55:21 +08:00
28 changed files with 1502 additions and 317 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "npm --prefix ../.. exec tauri -- dev",
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
"dev": "node scripts/start-tauri-dev.mjs",
"game-chat": "node scripts/start-tauri-dev.mjs --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
@@ -452,10 +452,7 @@ async function runConfigWizardRegressionChecks() {
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(
fs.realpathSync.native(realConfigAncestor),
'missing-appdata',
),
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
@@ -1260,6 +1257,21 @@ if (
);
}
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
throw new Error(
'AI game creator shell dev must run through the managed Tauri dev launcher',
);
}
if (
packageConfig.scripts?.['game-chat'] !==
'node scripts/start-tauri-dev.mjs --game-chat'
) {
throw new Error(
'AI game creator shell game-chat must run through the managed Tauri dev launcher',
);
}
const gameChatInitialUrlApply =
'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)';
const gameChatInitialUrlApplyIndexes = Array.from(
@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -134,6 +135,21 @@ async function readExistingViteServer() {
return httpGetText(viteUrl);
}
function isVitePortListening() {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: viteHost, port: vitePort });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
});
socket.once('error', () => resolveRequest(false));
socket.setTimeout(1000, () => {
socket.destroy();
resolveRequest(true);
});
});
}
function isAiGameCreatorServer(response) {
return (
response &&
@@ -144,17 +160,6 @@ function isAiGameCreatorServer(response) {
);
}
async function isExistingViteProxyReady() {
const response = await httpGetText(`${viteUrl}api/auth/me`, 2000);
return Boolean(
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
!response.body.includes('<title>AI 游戏创作</title>') &&
!response.body.includes('/src/main.tsx'),
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
if (!response || response.statusCode !== 200) {
@@ -167,24 +172,49 @@ async function readExistingViteMarker() {
}
}
async function isExistingVitePairedWithBackend(apiTarget) {
const marker = await readExistingViteMarker();
return Boolean(
marker &&
marker.schemaVersion === 1 &&
marker.app === 'ai-game-creator-shell' &&
marker.apiTarget === apiTarget,
async function preflightExistingVite({
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
} = {}) {
const existing = await readServer();
if (!existing) {
if (await portListening()) {
throw new Error(
`${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
}
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
const marker = await readMarker();
const markerApiTarget =
marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell' &&
typeof marker?.apiTarget === 'string'
? marker.apiTarget
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
function spawnChild(command, args, options, spawnImpl = spawn) {
const useShell = process.platform === 'win32';
const isPosix = process.platform !== 'win32';
const useShell = options.shell ?? !isPosix;
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: !useShell,
detached: isPosix,
stdio: 'inherit',
});
const lifecycle = {
@@ -192,7 +222,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
promise: null,
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null,
processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null,
};
lifecycle.promise = new Promise((resolveLifecycle) => {
child.once('error', (error) => {
@@ -270,6 +300,142 @@ function stopChild(child, signal = 'SIGTERM') {
}
}
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return true;
}
await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
}
return check();
}
function runWindowsTaskkill(
processId,
{ spawnImpl = spawn, timeoutMs = 5000 } = {},
) {
return new Promise((resolveRequest) => {
const taskkill = spawnImpl(
'taskkill.exe',
['/PID', String(processId), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true,
},
);
let settled = false;
const timeout = setTimeout(() => {
try {
taskkill.kill('SIGKILL');
} catch {
// ignore taskkill timeout races
}
finish({ timedOut: true, code: null, error: null });
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolveRequest(result);
};
taskkill.once('error', (error) =>
finish({ timedOut: false, code: null, error }),
);
taskkill.once('exit', (code) =>
finish({ timedOut: false, code: code ?? 0, error: null }),
);
});
}
async function terminateChildTree(
child,
{
platform = process.platform,
gracefulTimeoutMs = 2500,
forceTimeoutMs = 2000,
killImpl = process.kill,
taskkillImpl = runWindowsTaskkill,
} = {},
) {
if (!child) {
return { stopped: true, forced: false };
}
if (platform === 'win32') {
if (!Number.isInteger(child.pid)) {
stopChild(child, 'SIGTERM');
return { stopped: true, forced: false };
}
const result = await taskkillImpl(child.pid);
return {
stopped:
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0),
forced: true,
result,
};
}
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (!Number.isInteger(processGroupId)) {
stopChild(child, 'SIGTERM');
const lifecycle = childLifecycles.get(child);
if (lifecycle) {
await Promise.race([
lifecycle.promise,
new Promise((resolveWait) =>
setTimeout(resolveWait, gracefulTimeoutMs),
),
]);
}
if (child.exitCode == null && child.signalCode == null) {
stopChild(child, 'SIGKILL');
return { stopped: false, forced: true };
}
return { stopped: true, forced: false };
}
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
gracefulTimeoutMs,
)
) {
return { stopped: true, forced: false };
}
try {
killImpl(-processGroupId, 'SIGKILL');
} catch (error) {
if (error?.code !== 'ESRCH') {
return { stopped: false, forced: true, error };
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
forceTimeoutMs,
);
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
@@ -340,19 +506,9 @@ async function startVite(apiTarget) {
const existing = await readExistingViteServer();
if (existing) {
if (
isAiGameCreatorServer(existing) &&
(await isExistingVitePairedWithBackend(apiTarget)) &&
(await isExistingViteProxyReady())
) {
console.log(
`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`,
);
return null;
}
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`,
`${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
);
}
throw new Error(
@@ -384,6 +540,7 @@ async function main() {
}
try {
await preflightExistingVite();
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
@@ -422,6 +579,10 @@ async function main() {
);
return 1;
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(backendChild),
]);
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
@@ -439,10 +600,13 @@ export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
preflightExistingVite,
readChildFailure,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForBackendReady,
waitForChildTermination,
};
@@ -0,0 +1,128 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
preflightExistingVite,
spawnChild,
stopChild,
terminateChildTree,
waitForChildTermination,
} from './start-dev-stack.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
function parseLauncherArguments(argv) {
const args = [...argv];
const gameChat = args[0] === '--game-chat';
if (gameChat) {
args.shift();
}
return { gameChat, args };
}
function buildTauriArguments(argv) {
const { gameChat, args } = parseLauncherArguments(argv);
if (gameChat) {
return ['dev', '--', '--', '--game-chat', ...args];
}
return ['dev', ...args];
}
function spawnTauriCli(argv) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
shell: false,
});
}
async function runTauriDev(
argv = process.argv.slice(2),
{
preflight = preflightExistingVite,
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
} = {},
) {
await preflight();
const tauriArguments = buildTauriArguments(argv);
const child = spawnCli(tauriArguments);
let resolveShutdown;
let shutdownSignal = '';
let repeatedSignal = false;
const shutdownRequested = new Promise((resolveRequest) => {
resolveShutdown = resolveRequest;
});
const signalHandlers = new Map();
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
if (!shutdownSignal) {
shutdownSignal = signal;
stopChild(child, 'SIGTERM');
resolveShutdown(signal);
return;
}
repeatedSignal = true;
stopChild(child, 'SIGKILL');
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const childResult = waitForCli(child);
const outcome = await Promise.race([
childResult.then((failure) => ({ type: 'exit', failure })),
shutdownRequested.then((signal) => ({ type: 'signal', signal })),
]);
const cleanup = await terminateTree(child, {
gracefulTimeoutMs: repeatedSignal ? 0 : 2500,
});
if (!cleanup.stopped) {
console.error(
'[ai-game-creator-shell] Tauri dev exited, but its process tree could not be fully stopped.',
);
return 1;
}
if (outcome.type === 'signal') {
return 1;
}
const { failure } = outcome;
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} finally {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
export {
buildTauriArguments,
isDirectModuleExecution,
parseLauncherArguments,
runTauriDev,
spawnTauriCli,
};
if (isDirectModuleExecution()) {
try {
process.exitCode = await runTauriDev();
} catch (error) {
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}
@@ -262,11 +262,15 @@ fn autonomous_initial_delegate_expected_artifacts(
pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract(
plan: &AgentRuntimeToolPlan,
) -> Result<(), String> {
let mut code_prototype = None;
let mut quality_review = None;
let mut design_director = None;
let mut art_director = None;
let mut art_asset_plan = None;
let mut code_director = None;
for action in &plan.actions {
if action.tool.trim() == "agent.spawn_isolated" {
return Err(autonomous_initial_collaboration_contract_error(
"首批只允许激活 design-director、art-director、code-director,不得启动 isolated child",
));
}
let Some(input) = autonomous_initial_delegate_input(action)? else {
continue;
};
@@ -284,11 +288,14 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
));
};
let slot = match target_agent_id {
"code-prototype" => &mut code_prototype,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review,
"design-director" => &mut design_director,
"art-director" => &mut art_director,
"art-asset-plan" => &mut art_asset_plan,
_ => continue,
"code-director" => &mut code_director,
_ => {
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批只允许激活 design-director、art-director、code-director,不得委派底层 Agent:{target_agent_id}"
)));
}
};
if slot.replace(input).is_some() {
return Err(autonomous_initial_collaboration_contract_error(format!(
@@ -297,111 +304,80 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
}
}
let code_prototype = code_prototype.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派")
let design_director = design_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 design-director 委派")
})?;
let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?;
let code_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(code_prototype.clone()),
let design_task = autonomous_initial_delegate_task(design_director, "design-director")?;
let design_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(design_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(code_task.as_str())
if !std::iter::once(design_task.as_str())
.chain(design_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 design-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
));
}
let design_artifacts =
autonomous_initial_delegate_expected_artifacts(design_director, "design-director")?;
if !design_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 design-director 的 expectedArtifacts 必须为 []",
));
}
let art_director = art_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 art-director 委派")
})?;
let art_task = autonomous_initial_delegate_task(art_director, "art-director")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
let code_director = code_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
})?;
let code_task = autonomous_initial_delegate_task(code_director, "code-director")?;
let code_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(code_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if !std::iter::once(code_task.as_str())
.chain(code_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 code-prototype 必须是非只读实现任务",
"首批 code-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
));
}
let code_artifacts =
autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?;
if !code_artifacts
.iter()
.any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH)
{
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}"
)));
}
let quality_review = quality_review.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派")
})?;
let quality_task =
autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?;
let quality_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(quality_review.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if !std::iter::once(quality_task.as_str())
.chain(quality_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
autonomous_initial_delegate_expected_artifacts(code_director, "code-director")?;
if !code_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目",
"首批 code-director 的 expectedArtifacts 必须为 []",
));
}
let quality_artifacts = autonomous_initial_delegate_expected_artifacts(
quality_review,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID,
)?;
if !quality_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(
"首批 quality-review 的 expectedArtifacts 必须为 []",
));
}
if let Some(art_director) = art_director {
let art_task = autonomous_initial_delegate_task(art_director, "art-director")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_director.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
}
if let Some(art_asset_plan) = art_asset_plan {
let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_asset_plan.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-asset-plan 必须是非只读美术生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?;
let missing_artifacts = ["assets/manifest.art.json", "assets/art-spritesheet.png"]
.into_iter()
.filter(|required| !art_artifacts.iter().any(|path| path == required))
.collect::<Vec<_>>();
if !missing_artifacts.is_empty() {
return Err(autonomous_initial_collaboration_contract_error(format!(
"首批 art-asset-plan 的 expectedArtifacts 缺少:{}",
missing_artifacts.join(", ")
)));
}
}
Ok(())
}
@@ -1506,6 +1482,24 @@ pub(in crate::agent) fn restrict_agent_runtime_supervisor_collaboration_repair_t
Ok(())
}
pub(in crate::agent) fn restrict_agent_runtime_autonomous_initial_collaboration_repair_tools(
request: &mut LlmRunRequest,
) -> Result<(), String> {
let delegate_function = native_runtime_function_name("agent.delegate")
.ok_or_else(|| "无法生成 autonomous 首批协作修复工具名:agent.delegate".to_string())?;
request
.function_tools
.retain(|tool| tool.name == delegate_function);
if !request
.function_tools
.iter()
.any(|tool| tool.name == delegate_function)
{
return Err("autonomous 首批协作修复工具目录缺少 agent.delegate".to_string());
}
Ok(())
}
pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collaboration_repair(
error: &str,
) -> bool {
@@ -1522,6 +1516,93 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo
mod tests {
use super::*;
fn autonomous_initial_delegate(
agent_id: &str,
expected_artifacts: &[&str],
) -> AgentRuntimeToolAction {
let read_only_planner = matches!(agent_id, "design-director" | "code-director");
AgentRuntimeToolAction {
tool: "agent.delegate".to_string(),
reason: Some("建立首批 Leader 规划".to_string()),
input: serde_json::json!({
"agentId": agent_id,
"task": if read_only_planner {
format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目")
} else {
format!("由 {agent_id} 完成首轮专业交付")
},
"acceptanceCriteria": if read_only_planner {
vec!["只读给出可供后续底层 Agent 按需执行的规划,不得修改项目"]
} else {
vec!["视觉规范可供后续底层 Agent 按需执行"]
},
"expectedArtifacts": expected_artifacts,
"repairOfDelegationId": null,
"runId": null,
}),
}
}
fn autonomous_initial_leader_plan() -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
thinking_summary: "首批只激活程策美 Leader".to_string(),
plan_update: None,
plan: Vec::new(),
actions: vec![
autonomous_initial_delegate("design-director", &[]),
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
autonomous_initial_delegate("code-director", &[]),
],
response: String::new(),
}
}
#[test]
fn autonomous_initial_collaboration_accepts_only_three_leaders() {
validate_agent_runtime_autonomous_initial_collaboration_contract(
&autonomous_initial_leader_plan(),
)
.expect("three leader initial contract");
}
#[test]
fn autonomous_initial_collaboration_rejects_bottom_agent() {
let mut plan = autonomous_initial_leader_plan();
plan.actions[2] =
autonomous_initial_delegate("code-prototype", &[AGENT_RUNTIME_GAME_INDEX_PATH]);
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("bottom agent must be rejected");
assert!(error.contains("不得委派底层 Agent:code-prototype"));
}
#[test]
fn autonomous_initial_collaboration_rejects_isolated_child() {
let mut plan = autonomous_initial_leader_plan();
plan.actions.push(AgentRuntimeToolAction {
tool: "agent.spawn_isolated".to_string(),
reason: None,
input: serde_json::json!({"children": [], "joinMode": "all"}),
});
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("isolated child must be rejected");
assert!(error.contains("不得启动 isolated child"));
}
#[test]
fn autonomous_initial_collaboration_requires_leader_artifacts() {
let mut plan = autonomous_initial_leader_plan();
plan.actions[1] = autonomous_initial_delegate("art-director", &[]);
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("art artifact must be required");
assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png"));
}
#[test]
fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() {
let temporary = tempfile::tempdir().expect("create manifest DAG policy root");
@@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
.collect::<Vec<_>>()
})
.unwrap_or_default();
for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] {
for agent_id in ["design-director", "art-director", "code-director"] {
if error.contains(&format!("缺少 {agent_id} 委派"))
&& !missing.iter().any(|value| value == agent_id)
{
@@ -62,6 +62,31 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
missing
}
fn is_autonomous_initial_leader_delegate_action(action: &AgentRuntimeToolAction) -> bool {
if action.tool.trim() != "agent.delegate" {
return false;
}
let Some(input) = action.input.as_object() else {
return false;
};
if input
.get("repairOfDelegationId")
.or_else(|| input.get("repair_of_delegation_id"))
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
{
return false;
}
matches!(
input
.get("agentId")
.or_else(|| input.get("agent_id"))
.and_then(serde_json::Value::as_str)
.map(str::trim),
Some("design-director" | "art-director" | "code-director")
)
}
fn restrict_supervisor_collaboration_repair_to_missing_agents(
request: &mut LlmRunRequest,
error: &str,
@@ -843,9 +868,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
if force_supervisor_initial_collaboration {
supervisor_collaboration_repair_active = true;
if let Some(actions) = supervisor_collaboration_candidate_actions.take() {
supervisor_collaboration_repair_actions = actions;
supervisor_collaboration_repair_actions =
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
actions
.into_iter()
.filter(is_autonomous_initial_leader_delegate_action)
.collect()
} else {
actions
};
}
restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?;
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
restrict_agent_runtime_autonomous_initial_collaboration_repair_tools(
&mut request,
)?;
}
restrict_supervisor_collaboration_repair_to_missing_agents(
&mut request,
&protocol_error,
@@ -854,7 +892,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 agent.delegate。必须在同一响应一次性建立完整首批合同,且只允许以下三个非 repair 委派,各出现一次:design-director 与 code-director 的 task 或 acceptanceCriteria 必须显式声明只读且不得修改项目,expectedArtifacts 必须为 [];art-director 必须是非只读规范图生成任务,expectedArtifacts 必须包含 assets/art-spec.png。三者都必须提供非空 task、1-8 条 acceptanceCriteria,并设置 repairOfDelegationId=null、runId=null。不得委派 code-prototype、quality-review、design-foundation、art-asset-plan 或其它底层 Agent,不得调用 agent.spawn_isolated,不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
} else {
format!(
@@ -1081,12 +1119,42 @@ mod supervisor_collaboration_repair_tests {
.is_empty());
assert_eq!(
supervisor_collaboration_missing_agent_ids(
"Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0"
"Project Supervisor 首批协作不满足项目合同:missingStaticAgents=design-director,art-director,code-director · isolatedChildrenTotal=0"
),
vec!["code-prototype".to_string(), "quality-review".to_string()]
vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]
);
}
#[test]
fn autonomous_initial_repair_keeps_only_leader_delegates() {
let actions = [
collaboration_action(
"agent.delegate",
serde_json::json!({"agentId": "design-director", "repairOfDelegationId": null}),
),
collaboration_action(
"agent.delegate",
serde_json::json!({"agentId": "code-prototype", "repairOfDelegationId": null}),
),
collaboration_action(
"agent.spawn_isolated",
serde_json::json!({"children": [], "joinMode": "all"}),
),
];
let kept = actions
.iter()
.filter(|action| is_autonomous_initial_leader_delegate_action(action))
.map(|action| action.input["agentId"].as_str().expect("leader id"))
.collect::<Vec<_>>();
assert_eq!(kept, vec!["design-director"]);
}
#[test]
fn isolated_repair_replaces_the_single_accumulated_slot() {
let accumulated = vec![collaboration_action(
@@ -501,6 +501,90 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
assert_eq!(assistants, vec![response]);
}
#[test]
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
assert!(
!GameCreatorLlmConfig::default().stream,
"the production default exercises the non-stream final-reply path"
);
let project = tempfile::tempdir().expect("create non-stream professional reply project");
let root = project.path();
init_local_game_project_at(
root,
"non-stream-professional-reply",
"非流式专业 Agent 回复",
)
.expect("initialize non-stream professional reply project");
let mut state = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成 game-chat 首版统一视觉规范",
"non-stream-art-director-run",
"agent-delegate",
"整理专业 Agent 最终回复",
vec!["生成并登记统一视觉规范图".to_string()],
)
.expect("start non-stream professional runtime");
state.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string());
state.parent_run_id = Some("game-chat-parent-run".to_string());
state.delegation_id = Some("game-chat-art-director-delegation".to_string());
state.loop_iteration = 1;
state.status = "running".to_string();
state.phase = "response".to_string();
state.current_action = "直接采用非流式最终回复".to_string();
state.waiting_on = "finalization 持久化".to_string();
state.next_step = "提交 durable response stream 投影".to_string();
state.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &state)
.expect("append non-stream professional runtime task");
write_game_creator_agent_runtime_state(root, &state)
.expect("write non-stream professional runtime state");
let response_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read non-stream response revision")
.revision;
assert!(
read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id,)
.expect("read absent pre-finalization response stream")
.is_none(),
"stream=false must enter finalization without a pre-existing stream sidecar"
);
let response = "美术 Agent:统一视觉规范图已生成并登记。";
let completed = finish_game_creator_agent_background_runtime_turn_at(
root,
state.clone(),
response,
response_revision,
&[],
)
.expect("finalize non-stream professional reply");
assert!(matches!(
completed,
AgentBackgroundFinalizationOutcome::Completed(_)
));
let mut later_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before later stage mutation");
later_revision.revision = later_revision.revision.saturating_add(1);
later_revision.updated_at = unix_timestamp();
write_game_creator_agent_runtime_project_revision(root, &later_revision)
.expect("simulate a later game-chat stage advancing project revision");
let queried = read_game_creator_agent_runtime_at(root, &state.agent_id)
.expect("query completed professional runtime after revision advance");
let stream = queried
.response_stream
.expect("durable professional final reply remains queryable");
assert_eq!(
stream.status,
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED
);
assert_eq!(stream.request_kind, "final-reply");
assert_eq!(stream.response_revision, response_revision);
assert_eq!(stream.accumulated_text, response);
}
#[test]
fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
let (project, state, response_revision, snapshot) =
@@ -620,7 +620,12 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest
for task in new_game_creation_app_seed_tasks() {
if !matches!(
task.id.as_str(),
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
@@ -687,7 +692,12 @@ fn game_chat_single_round_cannot_converge_after_the_hard_budget() {
for task in new_game_creation_app_seed_tasks() {
if !matches!(
task.id.as_str(),
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
@@ -661,8 +661,14 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
.into_iter()
.filter_map(|mut task| {
let dependencies = match task.id.as_str() {
"design-director" => Some(Vec::new()),
"art-director" => Some(Vec::new()),
"code-prototype" => Some(vec!["art-director".to_string()]),
"code-director" => Some(Vec::new()),
"code-prototype" => Some(vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]),
"preview-readiness" => Some(vec!["code-prototype".to_string()]),
"preview-playtest" => Some(vec!["preview-readiness".to_string()]),
_ => None,
@@ -1325,13 +1325,17 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
));
}
};
// game-chat 可能在 UI 完成项目 hydration 前启动第一个 ready child,随后初始化写回会短暂把
// code-prototype 恢复成 Pending。child binding、owner artifact 和验证门仍能确认当前 run,
// 因此仅允许这个首任务收束,再由 terminal projection 写入权威 Completed 状态。
// game-chat 可能在 UI 完成项目 hydration 前并行启动 source-aware lane 的首波
// ready child,随后初始化写回会短暂把这些零依赖任务恢复成 Pending。child binding、owner
// artifact 和验证门仍能确认当前 run,因此仅允许当前 source 的零依赖首波收束,
// 再由 terminal projection 写入权威 Completed 状态。后续 preview 与中间任务继续
// 严格要求 Running/Completed,不得借 hydration 例外越过依赖。
// GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。
let game_chat_hydration_pending = state.agent_id == "code-prototype"
&& root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
&& task.status == GameCreationAppTaskStatus::Pending;
let game_chat_hydration_pending = root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
&& task.status == GameCreationAppTaskStatus::Pending
&& crate::agent::autonomous_manifest_seed_tasks_for_source(&root_source)
.iter()
.any(|seed_task| seed_task.id == state.agent_id && seed_task.dependencies.is_empty());
if !matches!(
task.status,
GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed
@@ -72,7 +72,7 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() {
}
#[test]
fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() {
fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_task_lane() {
let game_chat_tasks =
autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE);
assert_eq!(
@@ -81,23 +81,31 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() {
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
[
"design-director",
"art-director",
"code-director",
"code-prototype",
"preview-readiness",
"preview-playtest",
]
);
assert_eq!(game_chat_tasks[0].dependencies, Vec::<String>::new());
assert_eq!(game_chat_tasks[1].dependencies, Vec::<String>::new());
assert_eq!(game_chat_tasks[2].dependencies, Vec::<String>::new());
assert_eq!(
game_chat_tasks[1].dependencies,
vec!["art-director".to_string()]
game_chat_tasks[3].dependencies,
vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]
);
assert_eq!(
game_chat_tasks[2].dependencies,
game_chat_tasks[4].dependencies,
vec!["code-prototype".to_string()]
);
assert_eq!(
game_chat_tasks[3].dependencies,
game_chat_tasks[5].dependencies,
vec!["preview-readiness".to_string()]
);
@@ -116,7 +124,12 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() {
for task in &mut manifest_tasks {
if matches!(
task.id.as_str(),
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
task.status = GameCreationAppTaskStatus::Pending;
}
@@ -126,14 +139,20 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() {
&manifest_tasks,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
),
vec!["art-director".to_string()]
vec![
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
]
);
manifest_tasks
.iter_mut()
.find(|task| task.id == "art-director")
.expect("art director task exists")
.status = GameCreationAppTaskStatus::Completed;
for task_id in ["design-director", "art-director", "code-director"] {
manifest_tasks
.iter_mut()
.find(|task| task.id == task_id)
.unwrap_or_else(|| panic!("{task_id} task exists"))
.status = GameCreationAppTaskStatus::Completed;
}
assert_eq!(
autonomous_manifest_ready_task_ids(
&manifest_tasks,
@@ -1080,7 +1099,12 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task
for task in new_game_creation_app_seed_tasks() {
if !matches!(
task.id.as_str(),
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
"design-director"
| "art-director"
| "code-director"
| "code-prototype"
| "preview-readiness"
| "preview-playtest"
) {
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
@@ -1367,15 +1391,52 @@ fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset
}
#[test]
fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() {
fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to_pending() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
"创建一轮星空收集游戏",
"game-chat-ready-child-hydration-parent",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
);
for task_id in ["design-director", "art-director", "code-director"] {
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("restore initial {task_id} to pending: {error}"));
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id);
let mut state = agent_runtime_state_from_task_record(&record);
assert!(
autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none(),
"bound game-chat initial child {task_id} must survive late hydration"
);
state.status = "completed".to_string();
state.phase = "completed".to_string();
assert!(
project_autonomous_manifest_ready_task_terminal_at(&root, &state)
.unwrap_or_else(|error| panic!("project completed {task_id}: {error}"))
);
}
let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest");
for task_id in ["design-director", "art-director", "code-director"] {
assert_eq!(
manifest
.tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status),
Some(&GameCreationAppTaskStatus::Completed)
);
}
}
#[test]
fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion() {
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
"创建一轮星空收集游戏",
"game-chat-code-pending-parent",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
);
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending)
.expect("restore code prototype to pending as late hydration can do");
.expect("leave later code prototype pending");
let code_record =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
let mut code_state = agent_runtime_state_from_task_record(&code_record);
@@ -1385,10 +1446,15 @@ fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pendi
"<!doctype html><html><body><img id=\"art\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const image=document.getElementById('art');context.drawImage(image,0,0,canvas.width,canvas.height);context.drawImage(image,0,0,96,96);context.drawImage(image,120,80,112,112);requestAnimationFrame(()=>{});</script></body></html>",
);
assert!(
autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(),
"the verified game-chat child owns the artifact and terminal projection will persist Completed"
);
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
.expect("later code child must keep the strict manifest status gate");
assert!(blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("status=pending")));
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
.expect("restore the later code child to its authoritative running state");
mark_verification_passed(&root, &code_state, "game.static_smoke");
code_state.status = "completed".to_string();
code_state.phase = "completed".to_string();
@@ -185,8 +185,6 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at(
if stream.task_id != state.task_id
|| stream.session_id != state.session_id
|| stream.applied_steer_cursor != state.applied_steer_cursor
|| stream.response_revision
!= read_game_creator_agent_runtime_project_revision(root)?.revision
|| stream.request_slot
!= game_creator_agent_runtime_response_stream_request_slot(
state,
@@ -195,10 +193,14 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at(
{
return Ok(None);
}
let response_revision_is_current = stream.response_revision
== read_game_creator_agent_runtime_project_revision(root)?.revision;
let visible = match stream.status.as_str() {
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING
| AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY => {
state.status == "running" && matches!(state.phase.as_str(), "response" | "finalizing")
response_revision_is_current
&& state.status == "running"
&& matches!(state.phase.as_str(), "response" | "finalizing")
}
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED => {
matches!(state.status.as_str(), "idle" | "completed") && state.phase == "completed"
@@ -157,7 +157,9 @@ mod tests {
)
.expect("bind game-chat run");
for task_id in [
"design-director",
"art-director",
"code-director",
"code-prototype",
"preview-readiness",
"preview-playtest",
@@ -178,13 +180,13 @@ mod tests {
assert!(!detail.contains("publish-package"), "{detail}");
assert!(
detail.contains(
"seedTaskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"
"seedTaskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"
),
"{detail}"
);
assert!(
detail
.contains("taskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"),
.contains("taskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"),
"{detail}"
);
@@ -1432,10 +1432,11 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
&root,
SupervisorCollaborationPolicy {
required_initial_wave: SupervisorInitialCollaborationWave::Static,
min_static_delegates: 2,
min_static_delegates: 3,
required_static_agent_ids: vec![
"code-prototype".to_string(),
"quality-review".to_string(),
"design-director".to_string(),
"art-director".to_string(),
"code-director".to_string(),
],
..SupervisorCollaborationPolicy::default()
},
@@ -1444,26 +1445,36 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
let (sender, receiver) = mpsc::channel();
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",
let design_arguments = serde_json::json!({
"reason": "委派策划 Leader",
"input": {
"agentId": "code-prototype",
"task": "实现可直接试玩的游戏原型",
"acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"],
"expectedArtifacts": ["game/index.html"],
"agentId": "design-director",
"task": "只读拆解首轮玩法目标和专业分工,不得修改项目",
"acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的策划规划,不得修改项目"],
"expectedArtifacts": [],
"repairOfDelegationId": null,
"runId": null
}
})
.to_string();
let quality_arguments = serde_json::json!({
"reason": "委派质量评审 Agent",
let art_arguments = serde_json::json!({
"reason": "委派美术 Leader",
"input": {
"agentId": "quality-review",
"task": "只读评审可玩性与闯关闭环,不要修改任何项目文件",
"acceptanceCriteria": ["只读指出阻塞试玩的具体问题并给出验收结论"],
"agentId": "art-director",
"task": "确定首轮原创视觉方向",
"acceptanceCriteria": ["视觉规范可供后续底层 Agent 按需执行"],
"expectedArtifacts": ["assets/art-spec.png"],
"repairOfDelegationId": null,
"runId": null
}
})
.to_string();
let code_arguments = serde_json::json!({
"reason": "委派程序 Leader",
"input": {
"agentId": "code-director",
"task": "只读拆解首轮程序实现边界,不得修改项目",
"acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的程序规划,不得修改项目"],
"expectedArtifacts": [],
"repairOfDelegationId": null,
"runId": null
@@ -1473,15 +1484,22 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
let base_url = spawn_mock_llm_raw_responses_with_capture(
vec![
native_agent_tool_plan_chat_response(
"call-supervisor-initial-code-delegate",
"call-supervisor-initial-design-delegate",
delegate_function.as_str(),
code_arguments,
),
native_agent_tool_plan_chat_response(
"call-supervisor-read-only-quality-delegate",
delegate_function.as_str(),
quality_arguments,
design_arguments,
),
native_agent_tool_plan_chat_response_with_calls(vec![
(
"call-supervisor-art-director-delegate",
delegate_function.as_str(),
art_arguments,
),
(
"call-supervisor-code-director-delegate",
delegate_function.as_str(),
code_arguments,
),
]),
],
Some(sender),
);
@@ -1511,13 +1529,13 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
let runtime = start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"并行完成原型实现与质量评审",
"并行完成程策美 Leader 首轮规划",
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
"读取后建立首批协作",
vec![
"读取必要上下文".to_string(),
"一次性委派两个专业 Agent".to_string(),
"一次性委派三个 Leader Agent".to_string(),
],
)
.expect("start supervisor runtime");
@@ -1535,13 +1553,14 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
.await
.expect("repair read-only initial collaboration plan")
.expect("repaired collaboration plan");
assert_eq!(plan.actions.len(), 2);
assert_eq!(plan.actions.len(), 3);
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");
assert_eq!(plan.actions[0].input["agentId"], "design-director");
assert_eq!(plan.actions[1].input["agentId"], "art-director");
assert_eq!(plan.actions[2].input["agentId"], "code-director");
let initial_request = receiver
.recv_timeout(Duration::from_secs(2))
@@ -1550,7 +1569,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
let repair_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("supervisor read-only collaboration repair request");
assert!(repair_request.contains("missingStaticAgents=quality-review"));
assert!(repair_request.contains("missingStaticAgents=art-director,code-director"));
let repair_request_json = mock_http_request_json(&repair_request);
let repair_function_names = repair_request_json["tools"]
.as_array()
@@ -1568,7 +1587,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
.collect::<BTreeSet<_>>();
assert_eq!(
repair_function_names,
BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()])
BTreeSet::from([delegate_function.as_str()])
);
let delegate_schema = repair_request_json["tools"]
.as_array()
@@ -1590,7 +1609,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
.expect("delegate parameters");
assert_eq!(
parameters["properties"]["input"]["properties"]["agentId"]["enum"],
serde_json::json!(["quality-review"])
serde_json::json!(["art-director", "code-director"])
);
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
@@ -1613,7 +1632,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
})
.expect("repaired collaboration protocol audit");
assert_eq!(protocol["repairAttempt"], 1);
assert_eq!(protocol["functionCallCount"], 1);
assert_eq!(protocol["functionCallCount"], 2);
let collaboration_state = read_supervisor_collaboration_state_at(
&root,
+3 -3
View File
@@ -268,7 +268,9 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & {
};
const GAME_CHAT_STAGE_TASK_IDS = [
'design-director',
'art-director',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
@@ -5670,9 +5672,7 @@ export function App({
prompt,
runtime: runtimeAtSubmission,
runProfile: submissionRunProfile,
...(gameChatOnly
? { source: 'project-supervisor-game-chat' }
: {}),
...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}),
});
const runtimeResult = submission.runtimeResult;
const acceptedRunId = submission.acceptedRunId.trim();
@@ -58,7 +58,9 @@ export type GameChatRuntimeEvent = {
const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:';
const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:';
const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([
'design-director',
'art-director',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
@@ -324,7 +326,9 @@ export function buildGameChatProgressEvidence(
return null;
}
const fastPathTaskIds = new Set([
'design-director',
'art-director',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
@@ -167,7 +167,9 @@ function gameChatRuntimeState(
}
const GAME_CHAT_STAGE_TASK_IDS = [
'design-director',
'art-director',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
@@ -3383,8 +3385,10 @@ export function registerProjectSupervisorSurfaceTests() {
updatedAt: 200 + responseRevision,
});
const messages = gameChatFinalReplyMessages([
makeStream('design-director', 'committed', '玩法方向已完成'),
makeStream('art-director', 'ready', '视觉方向已完成'),
makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'),
makeStream('code-director', 'ready', '程序方案已完成'),
makeStream('code-prototype', 'ready', '代码原型已完成'),
makeStream('preview-readiness', 'committed', '预览就绪检查已完成'),
makeStream('preview-playtest', 'ready', '试玩验证已完成'),
@@ -3393,21 +3397,27 @@ export function registerProjectSupervisorSurfaceTests() {
requestKind: 'tool-plan',
},
]);
expect(messages).toHaveLength(4);
expect(messages).toHaveLength(6);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('玩法方向已完成'),
expect.stringContaining('视觉方向已完成'),
expect.stringContaining('程序方案已完成'),
expect.stringContaining('代码原型已完成'),
expect.stringContaining('预览就绪检查已完成'),
expect.stringContaining('试玩验证已完成'),
]);
expect(messages[0]?.messageId).toContain('art-director');
expect(messages[0]?.messageId).toContain('design-director');
expect(messages[0]?.messageId).toContain('game-chat-final-reply:');
expect(messages.every((message) => message.agentId)).toBe(true);
const hydrated = mergeGameChatFinalReplyMessagesIntoHistory(
[messages[0]!],
messages,
);
expect(hydrated.filter((message) => message.messageId === messages[0]?.messageId)).toHaveLength(1);
expect(
hydrated.filter(
(message) => message.messageId === messages[0]?.messageId,
),
).toHaveLength(1);
});
it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => {
@@ -3462,6 +3472,7 @@ export function registerProjectSupervisorSurfaceTests() {
};
};
const professionalResults = [
makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170),
makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180),
makeRuntimeResult(
'art-asset-plan',
@@ -3469,8 +3480,14 @@ export function registerProjectSupervisorSurfaceTests() {
'平台美术图集已生成并登记',
190,
),
makeRuntimeResult('code-director', 'ready', '程序方案已完成', 195),
makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200),
makeRuntimeResult('preview-readiness', 'committed', '预览就绪已完成', 210),
makeRuntimeResult(
'preview-readiness',
'committed',
'预览就绪已完成',
210,
),
makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220),
];
const harness = createProjectSupervisorRuntimeHarness({
@@ -3523,14 +3540,14 @@ export function registerProjectSupervisorSurfaceTests() {
String(args?.messageId ?? '').startsWith('game-chat-final-reply:'),
);
await waitFor(() => {
expect(finalReplyAppends()).toHaveLength(4);
expect(finalReplyAppends()).toHaveLength(6);
});
rendered.unmount();
rendered = renderRelease();
await waitFor(() => {
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
});
expect(finalReplyAppends()).toHaveLength(4);
expect(finalReplyAppends()).toHaveLength(6);
rendered.unmount();
});
@@ -3615,10 +3632,7 @@ export function registerProjectSupervisorSurfaceTests() {
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(
args?.messageId ??
'',
).startsWith('game-chat-runtime-event:'),
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
@@ -4096,7 +4110,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)');
});
it('counts only the four first-playable fast-path tasks in game-chat progress', () => {
it('counts only the six first-playable tasks in game-chat progress', () => {
const manifest = createGameCreationAppManifest(
'game-chat-progress-total',
'game-chat-progress-total',
@@ -4116,7 +4130,7 @@ export function registerProjectSupervisorSurfaceTests() {
renderGameChatStatus({ runtime, manifest });
const progress = screen.getByLabelText('Supervisor 进度播报');
expect(progress.textContent).toContain('任务图 4/4');
expect(progress.textContent).toContain('任务图 6/6');
expect(progress.textContent).not.toContain('publish-strategy');
expect(progress.textContent).not.toContain('publish-package');
});
@@ -4203,8 +4217,10 @@ export function registerProjectSupervisorSurfaceTests() {
'game-chat-progress-broadcast',
'game-chat-progress-broadcast',
);
manifest.tasks = manifest.tasks.map((task, index) => {
if (index < 3) {
manifest.tasks = manifest.tasks.map((task) => {
if (
['design-director', 'art-director', 'code-director'].includes(task.id)
) {
return { ...task, status: 'completed' as const };
}
if (task.id === 'code-prototype') {
@@ -4294,16 +4310,12 @@ export function registerProjectSupervisorSurfaceTests() {
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
expect(within(progress).queryByText(/第 4 轮/u)).toBeNull();
expect(
within(progress).getByText(
'任务图 1/4 · 进行中 1 · 计划 1/3',
),
within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 1/3'),
).not.toBeNull();
expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull();
expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull();
expect(
within(progress).getByText(
'程序原型 Agent · 修复角色碰撞与重开逻辑',
),
within(progress).getByText('程序原型 Agent · 修复角色碰撞与重开逻辑'),
).not.toBeNull();
expect(within(progress).getByText('试玩未通过')).not.toBeNull();
expect(
@@ -4316,10 +4328,7 @@ export function registerProjectSupervisorSurfaceTests() {
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(
args?.messageId ??
'',
).startsWith('game-chat-runtime-event:'),
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(1);
@@ -4367,14 +4376,10 @@ export function registerProjectSupervisorSurfaceTests() {
});
await waitFor(() => {
expect(
within(progress).getByText('本轮生成进度'),
).not.toBeNull();
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
expect(within(progress).queryByText(/第 5 轮/u)).toBeNull();
expect(
within(progress).getByText(
'任务图 1/4 · 进行中 1 · 计划 2/3',
),
within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 2/3'),
).not.toBeNull();
expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull();
expect(within(progress).getByText('返工决定')).not.toBeNull();
@@ -4838,7 +4843,9 @@ export function registerProjectSupervisorSurfaceTests() {
task.id === 'preview-playtest'
? { ...task, status: 'failed' as const }
: [
'design-director',
'art-director',
'code-director',
'code-prototype',
'preview-readiness',
].includes(task.id)
@@ -4918,7 +4925,8 @@ export function registerProjectSupervisorSurfaceTests() {
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toBe(true);
});
@@ -4978,7 +4986,7 @@ export function registerProjectSupervisorSurfaceTests() {
(stageRecordAppends()[0]?.[1] as { message?: { content?: string } })
?.message?.content ?? '',
);
expect(stageRecord).toContain('3/4');
expect(stageRecord).toContain('5/6');
});
it('archives a terminal game-chat run restored during initial hydration exactly once', async () => {
@@ -5080,7 +5088,9 @@ export function registerProjectSupervisorSurfaceTests() {
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/tasks' },
});
fireEvent.submit((screen.getByRole('textbox') as HTMLTextAreaElement).form!);
fireEvent.submit(
(screen.getByRole('textbox') as HTMLTextAreaElement).form!,
);
await waitFor(() => {
expect(
invoke.mock.calls.some(
@@ -5767,13 +5777,16 @@ export function registerProjectSupervisorSurfaceTests() {
const previewStatusReads = driver.invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
).length;
await waitFor(() => {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
).length,
).toBeGreaterThan(previewStatusReads);
}, { timeout: 3000 });
await waitFor(
() => {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
).length,
).toBeGreaterThan(previewStatusReads);
},
{ timeout: 3000 },
);
expect(driver.readAuthorization()?.authorizationId).toBe(
steerAuthorizationId,
);
@@ -19,7 +19,7 @@ export async function assertPlanningAndStatusShortcutFlow(
screen.getByText(/最近 run 已通过,但当前本地预览未运行。 建议:\/run/),
).not.toBeNull();
expect(
screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/),
screen.getByText(/还有 3 个 ready 任务等待处理。 建议:\/tasks/),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '处理首个风险' }));
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run');
@@ -382,13 +382,13 @@ export async function assertPlanningAndStatusShortcutFlow(
'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
expect(dependencyMessage.textContent).toContain(
'状态:active 0 / carry 0 / ready 1 / 等待依赖 15',
'状态:active 0 / carry 0 / ready 1 / 等待依赖 13',
);
expect(dependencyMessage.textContent).toContain(
'美术组 / Asset 生成首版美术素材(art-asset-plan) · 等待:美术组 / Director 确定视觉方向与规范图(art-director);策划组 / Gameplay 确定玩法规格与界面原型(design-foundation)',
);
expect(dependencyMessage.textContent).toContain(
'美术组 / Director 确定视觉方向与规范图(art-director) · 等待:策划组 / Director 拆解创作方向(design-director)',
'策划组 / Gameplay 确定玩法规格与界面原型(design-foundation) · 等待:策划组 / Director 拆解创作方向(design-director);美术组 / Director 确定视觉方向与规范图(art-director)',
);
expect(dependencyMessage.textContent).toContain(
'边界:只整理任务依赖;不读取任务文件;不启动 run;不修改项目',
@@ -928,7 +928,7 @@ export async function assertPlanningAndStatusShortcutFlow(
'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
expect(qaMessage.textContent).toContain('Evaluator:通过 · 质量评审通过');
expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 1 · 失败 0');
expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 3 · 失败 0');
expect(qaMessage.textContent).toContain('静态自检:通过');
expect(qaMessage.textContent).toContain('试玩:待启动预览');
expect(qaMessage.textContent).toContain('产物:3 个');
@@ -107,7 +107,7 @@ export async function assertProjectAndDesignShortcutFlow(
).length;
submitChat('/brief');
expect(await screen.findByText(/项目简报:/)).not.toBeNull();
expect(screen.getByText(/任务:完成 0\/16 · ready 1/)).not.toBeNull();
expect(screen.getByText(/任务:完成 0\/16 · ready 3/)).not.toBeNull();
expect(screen.getByText(/最近 Run:run-main-shortcut-trace/)).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '查看下一步' }));
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/next');
@@ -297,7 +297,7 @@ export async function assertProjectAndDesignShortcutFlow(
'当前状态:最近 run run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
expect(mvpMessage.textContent).toContain(
'任务:完成 0/16 · ready 1 · 失败 0',
'任务:完成 0/16 · ready 3 · 失败 0',
);
expect(mvpMessage.textContent).toContain('预览:未启动');
expect(mvpMessage.textContent).toContain('资产:2 个');
@@ -7,9 +7,12 @@ import { describe, expect, test, vi } from 'vitest';
import {
ensureBackend,
preflightExistingVite,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForChildTermination,
} from '../scripts/start-dev-stack.mjs';
@@ -175,4 +178,100 @@ describe('AI 游戏创作启动子进程生命周期', () => {
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
});
test('Windows 通过 taskkill 收束 Tauri CLI 进程树', async () => {
const child = Object.assign(new EventEmitter(), {
pid: 4821,
exitCode: 1,
signalCode: null,
kill: vi.fn(),
});
const taskkillImpl = vi.fn(async () => ({
timedOut: false,
code: 0,
error: null,
}));
const result = await terminateChildTree(child, {
platform: 'win32',
taskkillImpl,
});
expect(taskkillImpl).toHaveBeenCalledWith(4821);
expect(result).toMatchObject({ stopped: true, forced: true });
});
test('Windows taskkill 固定携带 PID、整树和强制参数', async () => {
const taskkill = Object.assign(new EventEmitter(), {
kill: vi.fn(),
});
const spawnImpl = vi.fn(() => {
queueMicrotask(() => taskkill.emit('exit', 0));
return taskkill;
});
await expect(
runWindowsTaskkill(4821, { spawnImpl, timeoutMs: 100 }),
).resolves.toMatchObject({ timedOut: false, code: 0, error: null });
expect(spawnImpl).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '4821', '/T', '/F'],
expect.objectContaining({ shell: false, windowsHide: true }),
);
});
});
describe('AI 游戏创作 3080 启动前预检', () => {
const agcHtml = {
statusCode: 200,
body: '<html><title>AI 游戏创作</title><script src="/src/main.tsx"></script></html>',
};
test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => {
await expect(
preflightExistingVite({
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
schemaVersion: 1,
app: 'ai-game-creator-shell',
apiTarget: 'http://127.0.0.1:10001',
}),
}),
).rejects.toThrow(
'API target http://127.0.0.1:10001. Its owning worktree cannot be proven',
);
});
test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => {
await expect(
preflightExistingVite({
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
schemaVersion: 1,
app: 'ai-game-creator-shell',
apiTarget: 'http://127.0.0.1:10004',
}),
}),
).rejects.toThrow('Its owning worktree cannot be proven');
});
test('HTTP 探测无响应但端口已监听时失败关闭', async () => {
await expect(
preflightExistingVite({
readServer: async () => null,
portListening: async () => true,
}),
).rejects.toThrow('non-HTTP or unrecognized server');
});
test('3080 未监听时允许继续启动', async () => {
await expect(
preflightExistingVite({
readServer: async () => null,
portListening: async () => false,
}),
).resolves.toEqual({ status: 'available', apiTarget: '' });
});
});
@@ -0,0 +1,215 @@
import { EventEmitter } from 'node:events';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
runTauriDev,
} from '../scripts/start-tauri-dev.mjs';
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(path)) {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
throw new Error(`等待测试进程标记超时: ${path}`);
}
describe('AI 游戏创作 Tauri dev 启动参数', () => {
test('普通 dev 参数原样交给 Tauri CLI', () => {
expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']);
});
test('game-chat 参数进入应用参数区且保留项目参数', () => {
expect(
buildTauriArguments([
'--game-chat',
'--project-path',
'/tmp/example-game',
]),
).toEqual([
'dev',
'--',
'--',
'--game-chat',
'--project-path',
'/tmp/example-game',
]);
});
});
describe('AI 游戏创作 Tauri dev 生命周期', () => {
test('3080 预检失败时不启动 Tauri CLI', async () => {
const spawnCli = vi.fn();
await expect(
runTauriDev([], {
preflight: async () => {
throw new Error('stale 3080');
},
spawnCli,
}),
).rejects.toThrow('stale 3080');
expect(spawnCli).not.toHaveBeenCalled();
});
test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => {
const order: string[] = [];
const child = Object.assign(new EventEmitter(), {
pid: 1234,
exitCode: 1,
signalCode: null,
kill: vi.fn(),
});
const result = await runTauriDev([], {
preflight: async () => {
order.push('preflight');
},
spawnCli: () => {
order.push('spawn');
return child;
},
waitForCli: async () => {
order.push('exit');
return { type: 'exit', code: 1, signal: null };
},
terminateTree: async (receivedChild) => {
expect(receivedChild).toBe(child);
order.push('cleanup');
return { stopped: true, forced: false };
},
});
expect(result).toBe(1);
expect(order).toEqual(['preflight', 'spawn', 'exit', 'cleanup']);
});
const posixTest = process.platform === 'win32' ? test.skip : test;
posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-'));
const readyPath = join(tempDir, 'client-ready');
const stoppedPath = join(tempDir, 'client-stopped');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath, stoppedPath] = process.argv.slice(1);
process.on('SIGTERM', () => {
writeFileSync(stoppedPath, 'stopped');
process.exit(0);
});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { existsSync } = require('node:fs');
const { spawn } = require('node:child_process');
const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath, stoppedPath],
{ stdio: 'ignore' },
);
descendant.unref();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild;
try {
const result = await runTauriDev([], {
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, stoppedPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
});
expect(result).toBe(42);
await waitForFile(stoppedPath);
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, 'SIGKILL');
} catch {
// 进程组已经由启动器收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-'));
const readyPath = join(tempDir, 'client-ready');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath] = process.argv.slice(1);
process.on('SIGTERM', () => {});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { existsSync } = require('node:fs');
const { spawn } = require('node:child_process');
const [readyPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath],
{ stdio: 'ignore' },
);
descendant.unref();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild;
try {
const result = await runTauriDev([], {
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
terminateTree: (child) =>
terminateChildTree(child, {
gracefulTimeoutMs: 50,
forceTimeoutMs: 2000,
}),
});
expect(result).toBe(42);
expect(() => process.kill(-cliChild.pid, 0)).toThrow();
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, 'SIGKILL');
} catch {
// 进程组已经由启动器强制收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
});