Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
T
kdletters 5e16837f3d
Project CI / Repository checks (push) Successful in 1m34s
Project CI / Frontend tests (push) Successful in 3m5s
Project CI / Backend tests (push) Successful in 4m12s
Project CI / Native shell tests (push) Failing after 10m55s
恢复游戏创作 External Editor 配置入口
普通 Launcher、开发工作台和独立 game-chat 统一显示 External Editor 配置。

补齐配置可见性、密码输入和持久化回归测试。

同步配置静态门禁、PRD、技术合同与共享决策。
2026-08-12 18:43:55 +08:00

11171 lines
369 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
consumeInitialGameChatMessage,
latestGameChatPlayableRevision,
} from '../../src/App';
import type {
AgentRuntimeEventRecord,
AgentRuntimeState,
} from '../../src/app/types';
import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE } from '../../src/features/agent-runtime/model';
import {
buildGameChatProgressEvidence,
collectGameChatResultImages,
collectGameChatRuntimeEvents,
formatGameChatStageRecord,
gameChatFinalReplyMessages,
gameChatRuntimeEventMessages,
isGameChatStageRecordMessage,
mergeGameChatFinalReplyMessagesIntoHistory,
SupervisorChatOnlyView,
} from '../../src/features/project-workspace/SupervisorChatOnlyView';
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
} from '../../src/view/project-development/resourceCanvasLayoutModel';
import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
import {
RESOURCE_CANVAS_VERTICAL_INSET,
RESOURCE_SECTION_DEFAULT_HEIGHT,
RESOURCE_SECTION_HEIGHT_STEP,
} from '../../src/view/project-development/resourceSectionHeightModel';
import {
act,
agentRuntimeUserInputRequest,
App,
cleanup,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createProjectSupervisorRuntimeHarness,
emptyProjectPolicy,
expect,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GameChatReleaseApp,
type GameCreationAgentRunTrace,
it,
mockRoleAgentReply,
ProjectDevelopmentView,
projectSupervisorResponseStream,
React,
readFileSync,
render,
renderAppAt,
renderLauncherProjectsAt,
resolve,
screen,
submitChat,
vi,
waitFor,
within,
} from './harness';
function resourceGraphForInputs(args?: Record<string, unknown>) {
const resources =
(args?.resources as
| Array<{ resourceId: string; producerTaskId: string | null }>
| undefined) ?? [];
return {
resourceIds: resources.map(({ resourceId }) => resourceId),
referenceEdges: [],
taskFlows: [],
connectionIndex: resources.map(({ resourceId }) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: resources.flatMap((resource) =>
resource.producerTaskId
? [
{
resourceId: resource.resourceId,
taskId: resource.producerTaskId,
},
]
: [],
),
dependencyDepths: resources.map((resource) => ({
resourceId: resource.resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function installResourceCardIntersectionObserver() {
const instances: Array<{
callback: IntersectionObserverCallback;
observed: Set<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {
instances.push({
callback,
observed: this.observed,
observer: this as unknown as IntersectionObserver,
});
}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
return {
triggerVisible(elements?: Element[]) {
const instance = instances.at(-1);
if (!instance) {
throw new Error('resource card IntersectionObserver was not created');
}
const targets = elements ?? Array.from(instance.observed);
instance.callback(
targets.map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
instance.observer,
);
},
observedCount() {
return instances.at(-1)?.observed.size ?? 0;
},
};
}
function gameChatRuntimeEvent({
agentId = 'project-supervisor',
taskId = agentId,
sessionId = `${agentId}-session`,
runId = 'game-chat-supervisor-run',
eventType = 'agent.progress',
status = 'running',
phase = 'execution',
summary,
detail = null,
updatedAt,
eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`,
publicText = summary,
}: {
agentId?: string;
taskId?: string;
sessionId?: string;
runId?: string;
eventType?: string;
status?: string;
phase?: string;
summary: string;
detail?: string | null;
eventId?: string;
publicText?: string | null;
updatedAt: number;
}): AgentRuntimeEventRecord {
return {
schemaVersion: 'game-creator-runtime-event.v1',
agentId,
taskId,
sessionId,
runId,
source:
agentId === 'project-supervisor'
? 'project-supervisor-game-chat'
: 'agent-delegate',
eventId,
eventType,
status,
phase,
summary,
detail,
publicText,
updatedAt,
};
}
function gameChatRuntimeState(
overrides: Partial<AgentRuntimeState> = {},
): AgentRuntimeState {
return {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'game-chat-supervisor-session',
runId: 'game-chat-supervisor-run',
source: 'project-supervisor-game-chat',
status: 'running',
phase: 'execution',
currentTask: '生成首版游戏',
currentGoal: '完成可运行原型',
currentAction: '执行当前计划',
waitingOn: '',
nextStep: '继续执行',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: null,
updatedAt: 1,
...overrides,
};
}
const GAME_CHAT_STAGE_TASK_IDS = [
'design-director',
'art-director',
'art-asset-plan',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
] as const;
function isGameChatStageTask(taskId: string) {
return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId);
}
function gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt,
}: {
parentRunId: string;
revision: number;
updatedAt: number;
}): AgentRuntimeState {
const runId = `preview-playtest-${parentRunId}-${revision}`;
const sessionId = `preview-playtest-session-${revision}`;
return gameChatRuntimeState({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId,
runId,
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId,
status: 'completed',
phase: 'completed',
currentTask: '执行浏览器试玩验证',
currentGoal: '确认当前 revision 可以试玩',
currentAction: '浏览器试玩验证已通过',
recentEvents: [
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId,
runId,
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validateok · 浏览器验证已通过',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision,
}),
updatedAt,
}),
],
updatedAt,
});
}
function renderGameChatStatus({
runtime,
runtimeByAgentId = {},
manifest = null,
viewOverrides = {},
}: {
runtime: AgentRuntimeState;
runtimeByAgentId?: Record<string, AgentRuntimeState | undefined>;
manifest?: ReturnType<typeof createGameCreationAppManifest> | null;
viewOverrides?: Partial<React.ComponentProps<typeof SupervisorChatOnlyView>>;
}) {
return render(
React.createElement(SupervisorChatOnlyView, {
chatAgentBusy: false,
chatInput: '',
messagesRef: React.createRef<HTMLDivElement>(),
onCancelConfirmation: vi.fn(),
onChatInputChange: vi.fn(),
onCloseRuntimeConfig: vi.fn(),
onConfirmConfirmation: vi.fn(),
onOpenRuntimeConfig: vi.fn(),
onScroll: vi.fn(),
onShowEarlierMessages: vi.fn(),
onSubmit: vi.fn(),
onToolAction: vi.fn(),
onUserInput: vi.fn(),
pendingConfirmation: null,
projectPath: '/tmp/game-chat-status',
runtime,
runtimeConfigOpen: false,
runtimeError: '',
transientReply: '',
hasConversationControls: false,
hiddenConversationCount: 0,
needsUserInput: false,
visibleMessages: [],
workspaceStatus: '已打开',
gameChatMode: true,
runtimeByAgentId,
manifest,
projectReady: true,
...viewOverrides,
}),
);
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, reject, resolve };
}
type GameChatPreviewFixture = {
port: number;
root: string;
url: string;
};
async function renderGameChatAutoPreviewDriver({
projectPath,
port,
readPolicy,
startPreview,
}: {
projectPath: string;
port: number;
readPolicy?: (callIndex: number) => Promise<Record<string, unknown>>;
startPreview?: (
args: Record<string, unknown>,
callIndex: number,
) => Promise<GameChatPreviewFixture>;
}) {
let backgroundRuntimes: Array<Record<string, unknown>> = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => backgroundRuntimes,
});
const manifest = createGameCreationAppManifest(
projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race',
'game-chat-race',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
let parentRunId = '';
let currentPreview: GameChatPreviewFixture | null = null;
let policyCallCount = 0;
let startCallCount = 0;
const stoppedPreviews: GameChatPreviewFixture[] = [];
const invoke = vi.fn(
async (command: string, args: Record<string, unknown> = {}) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return currentPreview
? { status: 'running', ...currentPreview }
: { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_project_permission_policy') {
policyCallCount += 1;
return readPolicy
? readPolicy(policyCallCount)
: {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'start_local_game_preview') {
startCallCount += 1;
const started = startPreview
? await startPreview(args, startCallCount)
: {
url: `http://127.0.0.1:${port}`,
port,
root: `${projectPath}/game`,
};
currentPreview = started;
return started;
}
if (command === 'stop_local_game_preview_if_matches') {
const expected = args.expectedPreview as GameChatPreviewFixture;
const matches =
currentPreview?.url === expected?.url &&
currentPreview.port === expected.port &&
currentPreview.root === expected.root;
if (matches) {
stoppedPreviews.push(expected);
currentPreview = null;
}
return matches;
}
if (command === 'start_game_creator_supervisor_runtime_task') {
parentRunId = String(args.runId ?? '');
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
const send = within(surface).getByRole('button', { name: '发送' });
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
expect((send as HTMLButtonElement).disabled).toBe(false);
});
const submit = async (prompt: string) => {
const previousSubmissionCount = invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task' ||
command === 'steer_game_creator_agent_runtime_task',
).length;
fireEvent.change(composer, { target: { value: prompt } });
fireEvent.click(send);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task' ||
command === 'steer_game_creator_agent_runtime_task',
),
).toHaveLength(previousSubmissionCount + 1);
});
await waitFor(() => {
expect((send as HTMLButtonElement).disabled).toBe(false);
});
};
const emitValidation = async (revision: number, validatedAt: number) => {
harness.setProjectRevision(revision);
const runtime = gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt: validatedAt,
});
backgroundRuntimes = [runtime];
await act(async () => {
harness.emitAgentRuntime(runtime);
await Promise.resolve();
});
};
const readAuthorization = () => {
const raw = window.localStorage.getItem(
'genarrative.game-chat.auto-preview-authorization.v2',
);
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
};
return {
emitValidation,
get currentPreview() {
return currentPreview;
},
get parentRunId() {
return parentRunId;
},
harness,
invoke,
readAuthorization,
stoppedPreviews,
submit,
surface,
};
}
async function assertNewGameChatAuthorizationSupersedesDeferredAttempt(
deferredStage: 'policy' | 'start',
) {
const projectPath = `/tmp/game-chat-deferred-${deferredStage}`;
const deferredPolicy = createDeferred<Record<string, unknown>>();
const deferredStart = createDeferred<GameChatPreviewFixture>();
const policyResult = {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
const staleStartedPreview = {
url: 'http://127.0.0.1:4391',
port: 4391,
root: `${projectPath}/game`,
};
const driver = await renderGameChatAutoPreviewDriver({
projectPath,
port: 4391,
readPolicy: async (callIndex) =>
deferredStage === 'policy' && callIndex === 1
? deferredPolicy.promise
: policyResult,
startPreview: async (args, callIndex) => {
expect(args).toEqual({
projectPath,
expectedRevision: deferredStage === 'policy' ? 2 : callIndex,
});
if (deferredStage === 'start' && callIndex === 1) {
return deferredStart.promise;
}
return {
url: 'http://127.0.0.1:4391',
port: 4391,
root: `${projectPath}/game`,
};
},
});
await driver.submit('生成首个可玩版本');
await driver.emitValidation(1, 1000);
await waitFor(
() => {
const reachedDeferredStage = driver.invoke.mock.calls.some(
([command]) =>
command ===
(deferredStage === 'policy'
? 'read_project_permission_policy'
: 'start_local_game_preview'),
);
expect(reachedDeferredStage).toBe(true);
},
{ timeout: 2_500 },
);
const firstAuthorization = driver.readAuthorization();
expect(firstAuthorization?.authorizationId).toEqual(expect.any(String));
await driver.submit('同一 Run 内替换自动预览授权');
await waitFor(() => {
const nextAuthorization = driver.readAuthorization();
expect(nextAuthorization?.authorizationId).toEqual(expect.any(String));
expect(nextAuthorization?.authorizationId).not.toBe(
firstAuthorization?.authorizationId,
);
expect(nextAuthorization).toMatchObject({
afterRevision: 1,
afterValidatedAt: 1000,
projectPath,
runId: driver.parentRunId,
});
});
const replacementAuthorizationId =
driver.readAuthorization()?.authorizationId;
await act(async () => {
if (deferredStage === 'policy') {
deferredPolicy.resolve(policyResult);
await deferredPolicy.promise;
} else {
deferredStart.resolve(staleStartedPreview);
await deferredStart.promise;
}
await Promise.resolve();
await Promise.resolve();
});
if (deferredStage === 'start') {
await waitFor(() => {
expect(driver.stoppedPreviews).toEqual([staleStartedPreview]);
});
expect(driver.invoke).toHaveBeenCalledWith(
'stop_local_game_preview_if_matches',
{ projectPath, expectedPreview: staleStartedPreview },
);
} else {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
}
expect(driver.readAuthorization()?.authorizationId).toBe(
replacementAuthorizationId,
);
expect(screen.queryByLabelText('游戏运行')).toBeNull();
await driver.emitValidation(2, 2000);
expect(
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
).not.toBeNull();
const expectedStartCount = deferredStage === 'start' ? 2 : 1;
await waitFor(() => {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(expectedStartCount);
});
expect(
driver.invoke.mock.calls
.filter(([command]) => command === 'start_local_game_preview')
.at(-1),
).toEqual(['start_local_game_preview', { projectPath, expectedRevision: 2 }]);
expect(driver.readAuthorization()).toBeNull();
}
export function registerProjectWorkbenchFoundationTests() {
it('renders the first project workbench slice with honest disabled run and local approval UI', () => {
const manifest = createGameCreationAppManifest(
'workbench-draft',
'陶泥儿工作台测试',
);
expect(
manifest.tasks.find((task) => task.id === 'design-foundation'),
).toMatchObject({
title: '确定玩法规格与界面原型',
artifacts: [
'memory/project.md',
'game/game_design.md',
'assets/ui-prototype.png',
],
acceptanceCriteria: [
'核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图',
],
});
expect(
manifest.tasks.find((task) => task.id === 'art-asset-plan'),
).toMatchObject({
title: '生成首版美术素材',
artifacts: ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
acceptanceCriteria: [
'角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
],
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '陶泥儿工作台测试',
projectPath: '/tmp/workbench-draft',
manifest,
attachments: [
{
fileName: 'broken-reference.png',
mediaType: 'image/png',
status: 'failed',
error: '图片解码失败',
},
],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement(
'div',
{ 'aria-label': '测试项目总控' },
'项目总控对话内容',
),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(screen.getByLabelText('项目主视窗')).not.toBeNull();
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('测试项目总控')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
expect(screen.getByRole('article', { name: /策划 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /美术 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /程序 Agent/ })).not.toBeNull();
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
expect(runTab.getAttribute('data-unavailable')).toBe('true');
fireEvent.click(runTab);
expect(runTab.getAttribute('aria-selected')).toBe('false');
expect(
screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'),
).not.toBeNull();
expect(screen.getByLabelText('附件导入失败')).not.toBeNull();
expect(screen.getByText('broken-reference.png')).not.toBeNull();
expect(screen.getByText('图片解码失败')).not.toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: '审批配置,当前严格审批',
}),
);
expect(
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
).not.toBeNull();
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
fireEvent.click(riskApproval);
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
expect(
screen.getByRole('button', {
name: '审批配置,当前严格审批',
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更多小组' }));
expect(screen.getByRole('article', { name: /数值 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /音频 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
it('keeps four resource section heights independent across modes, projects, details, and viewport clamps', async () => {
function addSectionResources(
manifest: ReturnType<typeof createGameCreationAppManifest>,
) {
manifest.assets = [
{
id: 'section-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/section.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'section-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/section.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'section-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/section.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
];
manifest.versions = [
{
versionId: 'section-version',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
}
const manifestA = createGameCreationAppManifest(
'workbench-section-height-a',
'分区高度项目甲',
);
addSectionResources(manifestA);
const manifestB = createGameCreationAppManifest(
'workbench-section-height-b',
'分区高度项目乙',
);
addSectionResources(manifestB);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifestA.name,
projectPath: '/tmp/workbench-section-height-a',
manifest: manifestA,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
await screen.findByRole('button', { name: '缩小文档分区' });
const sectionHeight = (label: string) =>
screen
.getByRole('region', { name: label })
.style.getPropertyValue('--resource-section-height');
const sectionZoom = (label: string) =>
Number(
screen
.getByRole('region', { name: label })
.querySelector<HTMLElement>('[data-resource-section-plane]')?.dataset
.resourceSectionScale ?? 0,
);
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
expect(sectionHeight('项目版本')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
);
expect(sectionHeight('美术资源')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
);
expect(sectionHeight('音乐音效资源')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
);
expect(sectionZoom('文档')).toBe(1);
expect(sectionZoom('美术资源')).toBe(1);
const layoutUpdatesBeforeZoom = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.click(screen.getByRole('button', { name: '放大文档内容' }));
expect(sectionZoom('文档')).toBe(1.1);
expect(sectionZoom('项目版本')).toBe(1);
const documentZoomViewport = screen.getByLabelText('文档分区内容');
const ordinaryWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: -100,
});
expect(documentZoomViewport.dispatchEvent(ordinaryWheel)).toBe(true);
expect(ordinaryWheel.defaultPrevented).toBe(false);
expect(sectionZoom('文档')).toBe(1.1);
const zoomWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
clientX: 0,
clientY: 0,
ctrlKey: true,
deltaY: -100,
});
let zoomWheelDispatchResult = true;
act(() => {
zoomWheelDispatchResult = documentZoomViewport.dispatchEvent(zoomWheel);
});
expect(zoomWheelDispatchResult).toBe(false);
expect(zoomWheel.defaultPrevented).toBe(true);
await waitFor(() => expect(sectionZoom('文档')).toBeGreaterThan(1.1));
const zoomBeforeWebKitGesture = sectionZoom('文档');
const gestureStart = new Event('gesturestart', {
bubbles: true,
cancelable: true,
});
Object.defineProperties(gestureStart, {
clientX: { value: 0 },
clientY: { value: 0 },
});
const gestureChange = new Event('gesturechange', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(gestureChange, 'scale', { value: 1.1 });
act(() => {
documentZoomViewport.dispatchEvent(gestureStart);
documentZoomViewport.dispatchEvent(gestureChange);
documentZoomViewport.dispatchEvent(
new Event('gestureend', { bubbles: true, cancelable: true }),
);
});
await waitFor(() =>
expect(sectionZoom('文档')).toBeGreaterThan(zoomBeforeWebKitGesture),
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforeZoom);
const dependencyDocumentDecrease = screen.getByRole('button', {
name: '缩小文档分区',
});
dependencyDocumentDecrease.focus();
const layoutUpdatesBeforeResize = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.click(dependencyDocumentDecrease);
expect(document.activeElement).toBe(dependencyDocumentDecrease);
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
);
expect(sectionHeight('项目版本')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforeResize);
const dependencyOuterCanvas = screen.getByLabelText(
'资源依赖视图',
) as HTMLDivElement;
dependencyOuterCanvas.scrollLeft = 31;
dependencyOuterCanvas.scrollTop = 47;
fireEvent.scroll(dependencyOuterCanvas);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeOuterCanvas = screen.getByLabelText(
'资源类型视图',
) as HTMLDivElement;
expect(typeOuterCanvas.scrollLeft).toBe(0);
expect(typeOuterCanvas.scrollTop).toBe(0);
typeOuterCanvas.scrollLeft = 52;
typeOuterCanvas.scrollTop = 68;
fireEvent.scroll(typeOuterCanvas);
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
expect(sectionZoom('文档')).toBe(1);
fireEvent.click(screen.getByRole('button', { name: '放大文档内容' }));
expect(sectionZoom('文档')).toBe(1.1);
fireEvent.click(screen.getByRole('button', { name: '放大文档分区' }));
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT + RESOURCE_SECTION_HEIGHT_STEP}px`,
);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
expect(
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollLeft,
).toBe(31);
expect(
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
).toBe(47);
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
);
expect(sectionZoom('文档')).toBeGreaterThan(1.1);
const documentViewport = screen.getByLabelText(
'文档分区内容',
) as HTMLDivElement;
documentViewport.scrollTop = 44;
documentViewport.scrollLeft = 12;
fireEvent.scroll(documentViewport);
fireEvent.click(
await screen.findByRole('button', {
name: /打开资源详情:项目版本 版本 1/,
}),
);
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
const restoredDocumentViewport = screen.getByLabelText(
'文档分区内容',
) as HTMLDivElement;
expect(restoredDocumentViewport.scrollTop).toBe(44);
expect(restoredDocumentViewport.scrollLeft).toBe(12);
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
);
let canvasHeight = 420;
const canvas = screen.getByLabelText('资源依赖视图');
Object.defineProperty(canvas, 'clientHeight', {
configurable: true,
get: () => canvasHeight,
});
await act(async () => {
fireEvent(window, new Event('resize'));
await new Promise<void>((resolveFrame) =>
window.requestAnimationFrame(() => resolveFrame()),
);
});
const artIncrease = screen.getByRole('button', {
name: '放大美术资源分区',
}) as HTMLButtonElement;
fireEvent.click(artIncrease);
fireEvent.click(artIncrease);
fireEvent.click(artIncrease);
await waitFor(() => expect(artIncrease.disabled).toBe(true));
expect(sectionHeight('美术资源')).toBe(
`${canvasHeight - RESOURCE_CANVAS_VERTICAL_INSET}px`,
);
canvasHeight = 300;
await act(async () => {
fireEvent(window, new Event('resize'));
await new Promise<void>((resolveFrame) =>
window.requestAnimationFrame(() => resolveFrame()),
);
});
await waitFor(() =>
expect(sectionHeight('美术资源')).toBe(
`${canvasHeight - RESOURCE_CANVAS_VERTICAL_INSET}px`,
),
);
canvasHeight = 500;
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
projectName: manifestB.name,
projectPath: '/tmp/workbench-section-height-b',
manifest: manifestB,
}),
);
await act(async () => {
fireEvent(window, new Event('resize'));
await new Promise<void>((resolveFrame) =>
window.requestAnimationFrame(() => resolveFrame()),
);
});
await waitFor(() =>
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
),
);
expect(sectionZoom('文档')).toBe(1);
expect(
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
).toBe(0);
rendered.rerender(React.createElement(ProjectDevelopmentView, viewProps));
await waitFor(() =>
expect(sectionHeight('文档')).toBe(
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
),
);
expect(sectionZoom('文档')).toBeGreaterThan(1.1);
expect(
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollLeft,
).toBe(31);
expect(
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
).toBe(47);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(
(screen.getByLabelText('资源类型视图') as HTMLDivElement).scrollLeft,
).toBe(52);
expect(
(screen.getByLabelText('资源类型视图') as HTMLDivElement).scrollTop,
).toBe(68);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
fireEvent.click(
screen.getByRole('button', {
name: '恢复文档分区默认尺寸',
}),
);
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
});
it('renders one body-first card system in both layouts and separates detail from single-media playback', async () => {
const observer = installResourceCardIntersectionObserver();
const manifest = createGameCreationAppManifest(
'workbench-body-first-cards',
'本体化资源卡测试',
);
manifest.assets = [
{
id: 'hero-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'intro-video',
kind: 'video',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'theme-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/design.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
];
manifest.versions = [
{
versionId: 'version-initial',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
let layoutRevision = 0;
const imageDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 12,
dataUrl: imageDataUrl,
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 24,
content: '# 玩法摘要\n\n这是安全的卡片正文摘要。',
};
}
if (command === 'read_local_project_media_preview') {
const relativePath = String(args?.relativePath ?? '');
return relativePath.endsWith('.mp3')
? {
path: relativePath,
mediaType: 'audio/mpeg',
byteLen: 32,
dataUrl: 'data:audio/mpeg;base64,SUQz',
}
: {
path: relativePath,
mediaType: 'video/mp4',
byteLen: 48,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const play = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
const pause = vi
.spyOn(HTMLMediaElement.prototype, 'pause')
.mockImplementation(() => undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-body-first-cards',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const heroDetailButton = await screen.findByRole('button', {
name: /打开资源详情:美术资源 hero\.png/,
});
const heroCard = heroDetailButton.closest('.game-resource-card');
expect(heroCard?.textContent).not.toContain('hero.png');
expect(heroCard?.textContent).not.toContain('assets/hero.png');
expect(heroCard?.textContent).not.toContain('Agent 生成');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
await waitFor(() => expect(observer.observedCount()).toBe(5));
act(() => observer.triggerVisible());
await waitFor(() => {
expect(
heroCard
?.querySelector('.game-resource-card-visual > img')
?.getAttribute('src'),
).toBe('blob:mock-attachment-preview');
expect(screen.getByText(/这是安全的卡片正文摘要。/)).not.toBeNull();
});
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(false);
const videoControl = screen.getByRole('button', {
name: '播放 intro.mp4',
});
const videoCard = videoControl.closest('.game-resource-card');
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
fireEvent.loadedData(video!);
fireEvent.click(videoControl);
expect(screen.queryByRole('region', { name: 'intro.mp4' })).toBeNull();
expect(screen.getByLabelText('资源依赖视图')).not.toBeNull();
const pauseVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
pauseVideoControl.focus();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...manifest },
}),
);
const stableVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
expect(stableVideoControl).toBe(pauseVideoControl);
expect(document.activeElement).toBe(stableVideoControl);
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(true);
expect(play).toHaveBeenCalled();
});
expect(pause.mock.instances).toContain(video);
expect(screen.queryByRole('region', { name: 'theme.mp3' })).toBeNull();
fireEvent.click(heroDetailButton);
const heroFocus = await screen.findByRole('region', { name: 'hero.png' });
expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull();
expect(within(heroFocus).getByText('image/png')).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(1);
fireEvent.click(
within(heroFocus).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() => expect(observer.observedCount()).toBe(5));
act(() => observer.triggerVisible());
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
const typeVideoControl = await screen.findByRole('button', {
name: '播放 intro.mp4',
});
const typeVideo = typeVideoControl
.closest('.game-resource-card')
?.querySelector('video');
expect(typeVideo).not.toBeNull();
fireEvent.loadedData(typeVideo!);
fireEvent.click(typeVideoControl);
await waitFor(() =>
expect(
screen.getByRole('button', { name: '暂停 intro.mp4' }),
).not.toBeNull(),
);
pause.mockClear();
const search = screen.getByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'assets/hero.png' } });
await waitFor(() => expect(pause.mock.instances).toContain(typeVideo));
expect(screen.getByRole('button', { name: /hero\.png/ })).not.toBeNull();
expect(screen.queryByRole('button', { name: /intro\.mp4/ })).toBeNull();
const typeHeroCard = screen
.getByRole('button', { name: /hero\.png/ })
.closest('.game-resource-card');
await waitFor(() =>
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).not.toBeNull(),
);
fireEvent.error(
typeHeroCard!.querySelector('.game-resource-card-visual > img')!,
);
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).toBeNull();
expect(
typeHeroCard?.querySelector('.game-resource-card-placeholder'),
).not.toBeNull();
});
it('bounds visible preview concurrency, deduplicates requests, evicts old entries, and drops late project results', async () => {
const observer = installResourceCardIntersectionObserver();
const manifest = createGameCreationAppManifest(
'workbench-preview-scheduler',
'预览调度测试',
);
manifest.assets = Array.from({ length: 49 }, (_, index) => ({
id: `image-${index}`,
kind: 'character',
mediaType: 'image/png',
localPath: `assets/image-${index}.png`,
source: { kind: 'generated' as const },
}));
const pending: Array<{
path: string;
resolve: (value: {
path: string;
mediaType: string;
byteLen: number;
dataUrl: string;
}) => void;
}> = [];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
const path = String(args?.relativePath ?? '');
return new Promise((resolvePreview) => {
pending.push({ path, resolve: resolvePreview });
});
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const props = {
projectName: manifest.name,
projectPath: '/tmp/workbench-preview-scheduler',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(React.createElement(ProjectDevelopmentView, props));
await waitFor(() => expect(observer.observedCount()).toBe(49));
act(() => {
observer.triggerVisible();
observer.triggerVisible();
});
await waitFor(() => expect(pending).toHaveLength(3));
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(3);
for (let index = 0; index < 49; index += 1) {
await waitFor(() => expect(pending[index]).toBeDefined());
const request = pending[index]!;
act(() => {
request.resolve({
path: request.path,
mediaType: 'image/png',
byteLen: 1,
dataUrl: `data:image/png;base64,${window.btoa(String(index))}`,
});
});
}
await waitFor(() => {
expect(
document.querySelectorAll('.game-resource-card-visual > img'),
).toHaveLength(48);
});
const lateManifest = createGameCreationAppManifest(
'workbench-preview-scheduler-next',
'新项目',
);
lateManifest.assets = [
{
id: 'next-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/next.png',
source: { kind: 'generated' },
},
];
manifest.projectId = lateManifest.projectId;
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-next',
projectName: lateManifest.name,
manifest: lateManifest,
}),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: /next\.png/ })).not.toBeNull(),
);
await waitFor(() => expect(observer.observedCount()).toBe(1));
act(() => observer.triggerVisible());
await waitFor(() => expect(pending[49]).toBeDefined());
manifest.projectId = 'workbench-preview-scheduler-final';
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-final',
projectName: '最终空项目',
manifest: {
...createGameCreationAppManifest(
'workbench-preview-scheduler-final',
'最终空项目',
),
assets: [],
},
}),
);
const objectUrlCountBeforeLateResult = vi.mocked(URL.createObjectURL).mock
.calls.length;
act(() => {
pending[49]!.resolve({
path: 'assets/next.png',
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,LATE',
});
});
await act(async () => Promise.resolve());
expect(URL.createObjectURL).toHaveBeenCalledTimes(
objectUrlCountBeforeLateResult,
);
});
it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => {
const manifest = createGameCreationAppManifest(
'workbench-versions',
'版本工作台测试',
);
manifest.assets = [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/player.png',
source: { kind: 'generated' },
},
];
manifest.versions = [
{
versionId: 'version-root',
parentVersionId: null,
projectRevision: 3,
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
createdReason: 'initial',
createdAt: 100,
},
{
versionId: 'version-child',
parentVersionId: 'version-root',
projectRevision: 4,
resourceBindings: [
{ slotId: 'player', resourceId: 'asset-player' },
{ slotId: 'historical', resourceId: 'asset-removed' },
],
createdReason: 'agent-revision',
createdAt: 200,
},
];
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-versions',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const rootVersionButton = screen.getByRole('button', {
name: /打开资源详情:项目版本 版本 1/,
});
const childVersionButton = screen.getByRole('button', {
name: /打开资源详情:项目版本 版本 2/,
});
const rootVersionCard = rootVersionButton.closest('.game-resource-card');
const childVersionCard = childVersionButton.closest('.game-resource-card');
expect(rootVersionCard?.textContent).toContain('1 个直接子版本');
expect(childVersionCard?.textContent).toContain('暂无直接子版本');
expect(childVersionCard?.textContent).not.toContain('version-root');
fireEvent.click(childVersionButton);
const versionFocus = screen.getByRole('region', { name: '版本 2' });
expect(within(versionFocus).getByText('version-child')).not.toBeNull();
expect(within(versionFocus).getByText('version-root')).not.toBeNull();
expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull();
expect(
within(versionFocus).getByText(
'player → asset-playerhistorical → asset-removed',
),
).not.toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
const playerCard = screen
.getByRole('button', { name: /player\.png/ })
.closest('.game-resource-card');
expect(playerCard?.classList.contains('is-relation-version-binding')).toBe(
true,
);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await waitFor(() => {
const dependencyPlayerCard = screen
.getByRole('button', { name: /player\.png/ })
.closest('.game-resource-card');
expect(
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
).toBe(true);
});
});
it('opens text receipts in the central focus state and restores the resource list context', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-details',
'不应显示的项目标题',
);
render(
React.createElement(ProjectDevelopmentView, {
projectName: '不应显示的项目标题',
projectPath: '/tmp/workbench-resource-details',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
agentRuntimeSummaries: [
{
group: 'art',
label: '美术 Agent',
status: 'completed',
statusLabel: '已完成',
currentTask: '本轮工作已完成',
currentAction: null,
waitingOn: null,
completedCount: 4,
totalCount: 4,
},
],
agentResults: [
{
agentId: 'design-foundation',
runId: 'design-result-run',
label: '玩法策划 Agent',
title: '玩法策划 Agent 文本回执',
content: '策划回执正文',
updatedAt: 1,
},
{
agentId: 'art-asset-plan',
runId: 'art-result-run',
label: '美术资源计划 Agent',
title: '美术资源计划 Agent 文本回执',
content:
'# 美术计划\n\n- 仅有美术计划\n- 没有图片文件\n\n**等待实际素材生成。**',
updatedAt: 2,
},
],
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(screen.queryByText('不应显示的项目标题')).toBeNull();
expect(screen.queryByRole('button', { name: '回首页' })).toBeNull();
expect(screen.queryByRole('button', { name: '项目组' })).toBeNull();
expect(screen.getByText('仅完成计划')).not.toBeNull();
expect(
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const searchInput = screen.getByLabelText(
'搜索项目资源',
) as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '美术资源计划' } });
const resourceCanvas = screen.getByLabelText(
'资源类型视图',
) as HTMLDivElement;
resourceCanvas.scrollLeft = 48;
resourceCanvas.scrollTop = 36;
const artReceiptButton = screen.getByRole('button', {
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
});
const artReceiptCard = artReceiptButton.closest('.game-resource-card');
expect(artReceiptCard).not.toBeNull();
const originalStyle = artReceiptCard?.getAttribute('style');
fireEvent.pointerDown(artReceiptButton, {
pointerId: 7,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(artReceiptButton, {
pointerId: 7,
clientX: 240,
clientY: 32,
});
fireEvent.pointerUp(artReceiptButton, {
pointerId: 7,
clientX: 240,
clientY: 32,
});
fireEvent.pointerCancel(artReceiptButton, { pointerId: 7 });
expect(artReceiptCard?.getAttribute('style')).toBe(originalStyle);
expect(artReceiptCard?.classList.contains('is-dragging')).toBe(false);
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
);
expect(styles).not.toMatch(/\.game-resource-card\.is-dragging/);
fireEvent.click(artReceiptButton);
const workbenchStage = screen.getByLabelText('项目主视窗');
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
'resources.focused.document',
);
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
expect(screen.queryByRole('button', { name: '按类型' })).toBeNull();
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
const receiptFocus = screen.getByRole('region', {
name: '美术资源计划 Agent 文本回执',
});
expect(
await within(receiptFocus).findByRole('heading', { name: '美术计划' }),
).not.toBeNull();
expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull();
expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull();
expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull();
expect(
receiptFocus.querySelector('.game-resource-focus-body'),
).not.toBeNull();
expect(receiptFocus.querySelector('ul')).not.toBeNull();
expect(receiptFocus.querySelector('strong')).not.toBeNull();
expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage);
expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull();
expect(styles).not.toMatch(
/\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s,
);
fireEvent.click(
within(receiptFocus).getByRole('button', { name: '收起资源' }),
);
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
'resources.list',
);
expect(
(screen.getByLabelText('搜索项目资源') as HTMLInputElement).value,
).toBe('美术资源计划');
expect(
screen
.getByRole('button', { name: '按类型' })
.getAttribute('aria-pressed'),
).toBe('true');
const restoredCanvas = screen.getByLabelText(
'资源类型视图',
) as HTMLDivElement;
expect(restoredCanvas.scrollLeft).toBe(48);
expect(restoredCanvas.scrollTop).toBe(36);
const restoredCard = screen.getByRole('button', {
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
});
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
expect(document.activeElement).toBe(restoredCard);
fireEvent.click(restoredCard);
expect(
screen.getByRole('region', {
name: '美术资源计划 Agent 文本回执',
}),
).not.toBeNull();
fireEvent.keyDown(window, { key: 'Escape' });
const escapeRestoredCard = screen.getByRole('button', {
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
});
expect(document.activeElement).toBe(escapeRestoredCard);
});
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-media',
'资源媒体测试',
);
manifest.assets.push(
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/design.md',
source: { kind: 'generated' },
},
{
id: 'art-svg',
kind: 'icon',
mediaType: 'image/svg+xml',
localPath: 'assets/icon.svg',
source: { kind: 'generated' },
},
{
id: 'art-video',
kind: 'animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated' },
},
{
id: 'audio-bgm',
kind: 'bgm',
mediaType: 'audio/mpeg',
localPath: 'assets/bgm.mp3',
source: { kind: 'generated' },
},
{
id: 'blocked-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/blocked.md',
source: { kind: 'generated' },
},
);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_text_preview') {
if (args?.relativePath === 'game/blocked.md') {
throw new Error('项目权限策略要求用户确认:file.read');
}
expect(args).toMatchObject({
projectPath: '/tmp/workbench-resource-media',
relativePath: 'game/design.md',
});
return {
path: 'game/design.md',
mediaType: 'text/markdown',
byteLen: 64,
content:
'# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n<script>window.pwned = true</script>',
};
}
if (command === 'read_local_project_media_preview') {
if (args?.category === 'art') {
if (args?.relativePath === 'assets/intro.mp4') {
return {
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
byteLen: 128,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
return {
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
byteLen: 48,
dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
};
}
return {
path: 'assets/bgm.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源媒体测试',
projectPath: '/tmp/workbench-resource-media',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(await screen.findByRole('button', { name: /design\.md/ }));
const documentFocus = await screen.findByRole('region', {
name: 'design.md',
});
expect(
within(documentFocus).getByRole('heading', { name: '本地设计文档' }),
).not.toBeNull();
expect(documentFocus.querySelector('script')).toBeNull();
expect(documentFocus.querySelector('a')).toBeNull();
expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull();
fireEvent.click(
within(documentFocus).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(screen.getByRole('button', { name: /icon\.svg/ }));
const artDetails = await screen.findByRole('region', { name: 'icon.svg' });
expect(within(artDetails).getByText('assets/icon.svg')).not.toBeNull();
expect(within(artDetails).getByText('image/svg+xml')).not.toBeNull();
expect(within(artDetails).getByText('依赖层级')).not.toBeNull();
expect(screen.queryByLabelText('icon.svg 图片预览')).toBeNull();
expect(artDetails.querySelector('img')).toBeNull();
fireEvent.click(
within(artDetails).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(
screen.getByRole('button', {
name: '打开资源详情:美术资源 intro.mp4',
}),
);
const videoDetails = await screen.findByRole('region', {
name: 'intro.mp4',
});
expect(within(videoDetails).getByText('assets/intro.mp4')).not.toBeNull();
expect(within(videoDetails).getByText('video/mp4')).not.toBeNull();
expect(screen.queryByLabelText('intro.mp4 视频预览')).toBeNull();
expect(videoDetails.querySelector('video')).toBeNull();
fireEvent.click(
within(videoDetails).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(
screen.getByRole('button', {
name: '打开资源详情:音乐音效资源 bgm.mp3',
}),
);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '播放 bgm.mp3' }));
const audio = (await screen.findByLabelText(
'bgm.mp3 音频播放器',
)) as HTMLAudioElement;
expect(audio.controls).toBe(true);
expect(audio.getAttribute('src')).toBe('blob:mock-attachment-preview');
expect(screen.getByText('载入后显示')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'read_local_project_media_preview',
expect.objectContaining({ category: 'audio' }),
);
fireEvent.click(
within(screen.getByRole('region', { name: 'bgm.mp3' })).getByRole(
'button',
{ name: '收起资源' },
),
);
fireEvent.click(screen.getByRole('button', { name: /blocked\.md/ }));
expect((await screen.findByRole('alert')).textContent).toBe(
'当前项目策略要求先确认读取文档,确认后请关闭详情并重试',
);
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-focus-updates',
'资源焦点更新测试',
);
manifest.assets = [
{
id: 'focus-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/focus.mp3',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/focus.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-focus-updates',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest,
}),
);
fireEvent.click(
await screen.findByRole('button', {
name: '打开资源详情:音乐音效资源 focus.mp3',
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放 focus.mp3' }));
const audio = (await screen.findByLabelText(
'focus.mp3 音频播放器',
)) as HTMLAudioElement;
audio.focus();
expect(document.activeElement).toBe(audio);
const updatedManifest = {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'audio-director'
? { ...task, status: 'completed' as const }
: task,
),
assets: manifest.assets.map((asset) => ({
...asset,
source: { ...asset.source, taskId: 'audio-director' },
})),
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: updatedManifest,
}),
);
expect(document.activeElement).toBe(audio);
expect(screen.getByRole('region', { name: 'focus.mp3' })).not.toBe(
document.activeElement,
);
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull();
expect(document.activeElement).toBe(
screen.getByLabelText('搜索项目资源'),
);
});
expect(
screen
.queryAllByTitle('打开资源详情')
.some((card) => card.getAttribute('aria-pressed') === 'true'),
).toBe(false);
});
it('renders, filters, and destroys same-category resource dependency lines', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
'资源依赖图测试',
);
manifest.assets.push(
{
id: 'dependency-spec',
kind: 'ui-spec',
mediaType: 'application/json',
localPath: 'assets/spec-source.json',
source: {
kind: 'canvas',
taskId: 'task-1',
resourceId: 'canvas-spec-source',
},
},
{
id: 'dependency-ui',
kind: 'ui-prototype',
mediaType: 'application/json',
localPath: 'assets/ui-dependency.json',
source: {
kind: 'canvas',
taskId: 'task-2',
resourceId: 'canvas-ui-target',
referenceResourceIds: ['canvas-spec-source'],
},
},
{
id: 'unrelated-cycle',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/unrelated-cycle.json',
source: {
kind: 'canvas',
resourceId: 'canvas-unrelated',
referenceResourceIds: ['canvas-unrelated'],
},
},
);
const referenceId =
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
const selfReferenceId =
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
const crossReferenceId =
'asset-reference:["asset:dependency-spec","asset:unrelated-cycle"]';
const flowId = 'task-flow:["art-director","design-foundation"]';
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
expect(args?.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:dependency-spec',
manifestAssetId: 'dependency-spec',
producerTaskId: null,
}),
]),
);
return {
resourceIds: [
'asset:dependency-spec',
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:dependency-ui',
cyclic: false,
},
{
id: selfReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:unrelated-cycle',
targetResourceId: 'asset:unrelated-cycle',
cyclic: true,
},
{
id: crossReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:unrelated-cycle',
cyclic: false,
},
],
taskFlows: [
{
id: flowId,
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:dependency-spec'],
targetResourceIds: ['asset:dependency-ui'],
cyclic: false,
},
],
connectionIndex: [
{
resourceId: 'asset:dependency-spec',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdgeIds: [referenceId, crossReferenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:dependency-ui',
upstreamReferenceResourceIds: ['asset:dependency-spec'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:unrelated-cycle',
upstreamReferenceResourceIds: [
'asset:dependency-spec',
'asset:unrelated-cycle',
],
downstreamReferenceResourceIds: ['asset:unrelated-cycle'],
referenceEdgeIds: [selfReferenceId, crossReferenceId],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
},
],
dependencyDepths: [
{
resourceId: 'asset:dependency-spec',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
dependencyDepth: 1,
},
{
resourceId: 'asset:unrelated-cycle',
dependencyDepth: 0,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['asset:unrelated-cycle'],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const view = render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
let overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() => {
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(2);
expect(
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
).toHaveLength(0);
});
const dependencyCanvas = screen.getByLabelText('资源依赖视图');
const descriptionId = dependencyCanvas.getAttribute('aria-describedby');
expect(descriptionId).not.toBeNull();
const relationshipDescription = document.getElementById(descriptionId!);
expect(relationshipDescription?.textContent).toContain(
'ui-dependency.json(待视觉验收) 引用 spec-source.json',
);
expect(
overlay.querySelector(`[data-edge-id='${crossReferenceId}']`),
).toBeNull();
expect(relationshipDescription?.textContent).not.toContain(
'unrelated-cycle.json 引用 spec-source.json',
);
expect(relationshipDescription?.textContent).not.toContain('art-director');
expect(
Array.from(overlay.querySelectorAll('svg')).every(
(sectionOverlay) =>
sectionOverlay.getAttribute('aria-hidden') === 'true',
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(screen.queryByTestId('resource-dependency-overlay')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
overlay = await screen.findByTestId('resource-dependency-overlay');
const search = screen.getByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'ui-dependency' } });
await waitFor(() => {
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
});
fireEvent.change(search, { target: { value: '' } });
let sourceCard = screen.getByRole('button', {
name: /spec-source\.json/,
});
const targetCard = screen.getByRole('button', {
name: /ui-dependency\.json/,
});
const referenceSelector =
'[data-edge-kind="asset-reference"]' +
'[data-source-resource-id="asset:dependency-spec"]' +
'[data-target-resource-id="asset:dependency-ui"]';
const firstPath = await waitFor(() => {
const path = overlay.querySelector(referenceSelector);
expect(path).not.toBeNull();
return path?.getAttribute('d');
});
const sourceStyle = sourceCard.getAttribute('style');
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(sourceCard, {
pointerId: 27,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
fireEvent.pointerUp(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
fireEvent.pointerCancel(sourceCard, { pointerId: 27 });
expect(sourceCard.getAttribute('style')).toBe(sourceStyle);
expect(sourceCard.classList.contains('is-dragging')).toBe(false);
expect(overlay.querySelector(referenceSelector)?.getAttribute('d')).toBe(
firstPath,
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforePointer);
fireEvent.click(targetCard);
const targetFocus = screen.getByRole('region', {
name: /ui-dependency\.json/u,
});
fireEvent.click(
within(targetFocus).getByRole('button', { name: '收起资源' }),
);
overlay = await screen.findByTestId('resource-dependency-overlay');
sourceCard = screen.getByRole('button', { name: /spec-source\.json/ });
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).not.toBeNull(),
);
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false);
expect(
overlay
.querySelector(referenceSelector)
?.classList.contains('is-highlighted'),
).toBe(false);
expect(
overlay
.querySelector('[data-source-resource-id="asset:unrelated-cycle"]')
?.classList.contains('is-dimmed'),
).toBe(false);
const previousReferencePaths = Array.from(
overlay.querySelectorAll('[data-edge-kind]'),
);
const nextManifest = createGameCreationAppManifest(
'workbench-resource-graph-next',
'新资源依赖图测试',
);
view.rerender(
React.createElement(ProjectDevelopmentView, {
projectName: '新资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph-next',
manifest: nextManifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const nextOverlay = await screen.findByTestId(
'resource-dependency-overlay',
);
expect(previousReferencePaths).not.toHaveLength(0);
await waitFor(() => {
expect(
previousReferencePaths.every(
(previousReferencePath) => !previousReferencePath.isConnected,
),
).toBe(true);
expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull();
});
});
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
const referenceId = 'asset-reference:["resource-a","resource-b"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['resource-a', 'resource-b'],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'resource-a',
targetResourceId: 'resource-b',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [
{
resourceId: 'resource-a',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['resource-b'],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
{
resourceId: 'resource-b',
upstreamReferenceResourceIds: ['resource-a'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'resource-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'resource-b',
section: 'art',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function getSectionRect(this: HTMLElement) {
const rect = (
left: number,
top: number,
width: number,
height: number,
) =>
({
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: () => ({}),
}) as DOMRect;
if (this.classList.contains('test-resource-content')) {
return rect(0, 0, 600, 800);
}
if (this.dataset.resourceSectionScroll === 'art') {
return rect(0, 100, 600, 300);
}
if (this.dataset.resourceSectionPlane === 'art') {
const viewport = this.closest<HTMLElement>(
'[data-resource-section-scroll="art"]',
);
return rect(
-(viewport?.scrollLeft ?? 0),
100 - (viewport?.scrollTop ?? 0),
600,
400,
);
}
return originalGetBoundingClientRect.call(this);
},
);
const rendered = render(
React.createElement(
'div',
{ className: 'test-resource-outer' },
React.createElement(
'div',
{
className: 'test-resource-content',
'data-testid': 'resource-dependency-overlay',
},
React.createElement(
'section',
null,
React.createElement(
'div',
{
'data-resource-section-scroll': 'art',
},
React.createElement(
'div',
{
'data-resource-section-plane': 'art',
'data-resource-section-scale': '1',
},
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'art',
visibleResourceIds: new Set(['resource-a', 'resource-b']),
geometryRevision: '340@1',
}),
),
),
),
),
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const referenceSelector = `[data-edge-id='${referenceId}']`;
await waitFor(() => {
const referencePath = overlay.querySelector(referenceSelector);
expect(referencePath?.getAttribute('d')).toBeTruthy();
expect(referencePath?.getAttribute('data-route-axis')).toBe('vertical');
expect(referencePath?.getAttribute('marker-end')).toMatch(
/^url\(#.+-asset-reference-arrow\)$/u,
);
});
expect(observerCount).toBe(1);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-art"]')
?.parentElement?.getAttribute('data-resource-section-plane'),
).toBe('art');
expect(overlay.querySelector('clipPath')).toBeNull();
const viewport = rendered.container.querySelector<HTMLElement>(
'[data-resource-section-scroll="art"]',
);
if (!viewport) {
throw new Error('missing art section viewport');
}
const removeViewportListener = vi.spyOn(viewport, 'removeEventListener');
const referenceBeforeScroll = overlay.querySelector(referenceSelector);
expect(referenceBeforeScroll).not.toBeNull();
const frameCountBeforeScroll = animationFrame.mock.calls.length;
viewport.scrollTop = 80;
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
// A geometry frame can already be pending from the initial height pass.
// In that case every scroll reuses it; otherwise the first scroll schedules
// exactly one frame. Both cases satisfy the one-frame coalescing contract.
expect(
animationFrame.mock.calls.length - frameCountBeforeScroll,
).toBeLessThanOrEqual(1);
await waitFor(() => {
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-art"]')
?.getAttribute('data-logical-viewport'),
).toBe('0,80,600,300');
});
viewport.scrollTop = 0;
fireEvent.scroll(viewport);
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
),
);
rendered.unmount();
expect(observerDisconnected).toBe(true);
expect(removeViewportListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
);
});
it('waits for the scoped resource graph before initializing dependency layout', async () => {
const projectId = 'workbench-delayed-resource-graph';
const projectPath = '/tmp/workbench-delayed-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'delayed-graph-run',
label: '玩法策划 Agent',
title: '延迟依赖图回执',
content: '图就绪后再初始化布局',
updatedAt: 1,
},
];
let resolveGraph: (() => void) | null = null;
let dependencyLayoutReads = 0;
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return await new Promise((resolve) => {
resolveGraph = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
if (args?.mode === 'dependency') {
dependencyLayoutReads += 1;
}
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '延迟依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() => expect(resolveGraph).not.toBeNull());
expect(dependencyLayoutReads).toBe(0);
expect(
screen.queryByRole('button', {
name: '打开资源详情:文档 延迟依赖图回执',
}),
).toBeNull();
await act(async () => {
resolveGraph?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 延迟依赖图回执',
}),
).not.toBeNull();
expect(dependencyLayoutReads).toBe(1);
});
it('keeps resource cards mounted across equivalent rerenders and graph refreshes', async () => {
const projectId = 'workbench-stable-resource-graph';
const projectPath = '/tmp/workbench-stable-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '稳定依赖图测试');
const firstResult = {
agentId: 'design-foundation',
runId: 'stable-graph-run',
label: '玩法策划 Agent',
title: '稳定依赖图回执',
content: '轮询刷新时保留现有资源卡',
updatedAt: 1,
};
let graphReads = 0;
let layoutRevision = 0;
let resolveGraphRefresh: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphReads += 1;
if (graphReads === 1) {
return resourceGraphForInputs(args);
}
return await new Promise((resolve) => {
resolveGraphRefresh = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const renderView = (agentResults: ProjectAgentResultSummary[]) =>
React.createElement(ProjectDevelopmentView, {
projectName: '稳定依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
});
const view = render(renderView([firstResult]));
const firstCardButton = await screen.findByRole('button', {
name: '打开资源详情:文档 稳定依赖图回执',
});
const firstCard = firstCardButton.closest('.game-resource-card');
expect(firstCard).not.toBeNull();
expect(graphReads).toBe(1);
await act(async () => {
view.rerender(renderView([{ ...firstResult }]));
await Promise.resolve();
});
expect(graphReads).toBe(1);
expect(firstCard?.isConnected).toBe(true);
const secondResult = {
agentId: 'code-director',
runId: 'stable-graph-code-run',
label: '程序 Agent',
title: '刷新期间新增回执',
content: '新图返回前也不清空旧布局',
updatedAt: 2,
};
view.rerender(renderView([{ ...firstResult }, secondResult]));
await waitFor(() => expect(graphReads).toBe(2));
expect(resolveGraphRefresh).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
expect(
screen.getByRole('button', {
name: '打开资源详情:文档 稳定依赖图回执',
}),
).not.toBeNull();
await act(async () => {
resolveGraphRefresh?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 刷新期间新增回执',
}),
).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
});
it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => {
const projectId = 'workbench-truncated-resource-graph';
const projectPath = '/tmp/workbench-truncated-resource-graph';
const manifest = createGameCreationAppManifest(
projectId,
'截断依赖图布局保护测试',
);
manifest.assets.push(
{
id: 'truncated-depth-0',
kind: 'design-spec',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-0.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-0',
},
},
{
id: 'truncated-depth-1',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-1.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-1',
referenceResourceIds: ['external-truncated-depth-0'],
},
},
{
id: 'truncated-depth-2',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-2.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-2',
referenceResourceIds: ['external-truncated-depth-1'],
},
},
);
const slotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const existingPositions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'asset:truncated-depth-0',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-1',
section: 'document',
x: slotWidth,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-2',
section: 'document',
x: slotWidth * 2,
y: 0,
manuallyPlaced: false,
},
];
const layoutUpdates: ProjectResourceCanvasPosition[][] = [];
let layoutReads = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: existingPositions.map(({ resourceId }) => resourceId),
referenceEdges: [
{
id: 'reference:truncated-0-1',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-0',
targetResourceId: 'asset:truncated-depth-1',
cyclic: false,
},
{
id: 'reference:truncated-1-2',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-1',
targetResourceId: 'asset:truncated-depth-2',
cyclic: false,
},
],
taskFlows: [
{
id: 'flow:untrusted-producer',
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:truncated-depth-0'],
targetResourceIds: ['asset:truncated-depth-1'],
cyclic: true,
},
],
connectionIndex: [
{
resourceId: 'asset:truncated-depth-0',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['asset:truncated-depth-1'],
referenceEdgeIds: ['reference:truncated-0-1'],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-1',
upstreamReferenceResourceIds: ['asset:truncated-depth-0'],
downstreamReferenceResourceIds: ['asset:truncated-depth-2'],
referenceEdgeIds: [
'reference:truncated-0-1',
'reference:truncated-1-2',
],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-2',
upstreamReferenceResourceIds: ['asset:truncated-depth-1'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: ['reference:truncated-1-2'],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:truncated-depth-0',
taskId: 'art-director',
},
],
dependencyDepths: [
{
resourceId: 'asset:truncated-depth-0',
dependencyDepth: 0,
},
{
resourceId: 'asset:truncated-depth-1',
dependencyDepth: 1,
},
{
resourceId: 'asset:truncated-depth-2',
dependencyDepth: 2,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: ['art-director'],
producerMappingTruncated: true,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
layoutReads += 1;
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 7,
positions: structuredClone(existingPositions),
updatedAt: 7,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutUpdates.push(
structuredClone(args?.positions as ProjectResourceCanvasPosition[]),
);
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 8,
positions: structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
updatedAt: 8,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '截断依赖图布局保护测试',
projectPath,
manifest,
attachments: [],
agentResults: [],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() => expect(layoutReads).toBe(1));
const cards = [0, 1, 2].map((depth) =>
screen
.getByRole('button', {
name: new RegExp(`truncated-depth-${depth}\\.json`, 'u'),
})
.closest('.game-resource-card'),
);
await waitFor(() => {
expect(cards[0]?.getAttribute('style')).toContain('--resource-x: 0px');
expect(cards[1]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth}px`,
);
expect(cards[2]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth * 2}px`,
);
});
await act(async () => {
await Promise.resolve();
});
expect(layoutUpdates).toEqual([]);
});
it('restores historical resource positions but never moves or persists them from pointer input', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
const resourceId = 'agent-result:design-foundation:layout-result-run';
const manifest = createGameCreationAppManifest(projectId, '布局持久化测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'layout-result-run',
label: '玩法策划 Agent',
title: '布局持久化回执',
content: '布局持久化正文',
updatedAt: 1,
},
];
const persistedLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 1,
positions: [
{
resourceId,
section: 'document',
x: 12,
y: 24,
manuallyPlaced: true,
},
],
updatedAt: 100,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return structuredClone(persistedLayout);
}
if (command === 'update_local_project_resource_canvas_layout') {
throw new Error('pointer input must not submit a manual layout CAS');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
function renderWorkbench() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局持久化测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
}
renderWorkbench();
const cardButton = await screen.findByRole('button', {
name: '打开资源详情:文档 布局持久化回执',
});
const card = cardButton.closest('.game-resource-card');
expect(card).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
const originalStyle = card?.getAttribute('style');
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(cardButton, {
pointerId: 11,
button: 0,
clientX: 20,
clientY: 30,
});
fireEvent.pointerMove(cardButton, {
pointerId: 11,
clientX: 100,
clientY: 60,
});
fireEvent.pointerUp(cardButton, {
pointerId: 11,
clientX: 100,
clientY: 60,
});
fireEvent.pointerCancel(cardButton, { pointerId: 11 });
expect(card?.getAttribute('style')).toBe(originalStyle);
expect(card?.classList.contains('is-dragging')).toBe(false);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforePointer);
fireEvent.click(cardButton);
expect(
screen.getByRole('region', { name: '布局持久化回执' }),
).not.toBeNull();
cleanup();
renderWorkbench();
const restoredCard = (
await screen.findByRole('button', {
name: '打开资源详情:文档 布局持久化回执',
})
).closest('.game-resource-card');
await waitFor(() => {
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-x: 12px',
);
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-y: 24px',
);
});
});
it('maps manifest asset kinds into stable type layout ordering', async () => {
const projectId = 'workbench-layout-asset-subtypes';
const manifest = createGameCreationAppManifest(
projectId,
'资源子类型排序测试',
);
manifest.assets.push(
{
id: 'ui-prototype-first-by-label',
kind: 'ui-prototype',
mediaType: 'image/png',
localPath: 'assets/a-ui-prototype.png',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'art-spritesheet-last-by-label',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/z-art-spritesheet.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
);
const updates: Array<{
mode: 'dependency' | 'type';
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
const mode = args?.mode as 'dependency' | 'type';
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push({ mode, positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 1,
positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源子类型排序测试',
projectPath: '/tmp/workbench-layout-asset-subtypes',
manifest,
attachments: [],
agentResults: [],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'type')).toBe(true),
);
const typePositions = updates.find(
({ mode }) => mode === 'type',
)?.positions;
expect(typePositions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:art-spritesheet-last-by-label',
x: 0,
y: 0,
}),
expect.objectContaining({
resourceId: 'asset:ui-prototype-first-by-label',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
}),
]),
);
});
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 2,
positions: [],
updatedAt: 200,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
throw new Error('disk full');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局失败测试',
projectPath: '/tmp/workbench-layout-save-failure',
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'save-failure-run',
label: '玩法策划 Agent',
title: '自动排版失败回执',
content: '自动排版失败正文',
updatedAt: 1,
},
],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
expect(
await screen.findByText('布局保存失败,已保留当前会话布局'),
).not.toBeNull();
expect(
screen.getByRole('button', {
name: '打开资源详情:文档 自动排版失败回执',
}),
).not.toBeNull();
});
it('keeps the landscape workbench inside the viewport with internal chat scrolling', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - 72px\)[^}]*padding:\s*40px 8px 8px[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*grid-template-rows:\s*minmax\(96px, 1fr\) minmax\(0, auto\) auto auto auto[^}]*height:\s*100%[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-composer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 82px[^}]*z-index:\s*2[^}]*border-top:\s*1px solid var\(--platform-line-soft\)/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-composer button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
);
const agentDockRule = styles.match(/\.game-agent-dock\s*\{([^}]*)\}/s);
expect(agentDockRule?.[1]).toContain('overflow: visible;');
expect(agentDockRule?.[1]).not.toContain('overflow-x:');
expect(agentDockRule?.[1]).toContain('z-index: 30;');
expect(agentDockRule?.[1]).toContain('isolation: isolate;');
expect(agentDockRule?.[1]).toContain('min-height: 58px;');
expect(styles).toMatch(
/\.game-agent-dock-item\s*\{[^}]*flex:\s*1 1 128px[^}]*min-width:\s*0[^}]*max-width:\s*170px/s,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.game-workbench-layout\s*\{[^}]*height:\s*auto/,
);
expect(styles).toMatch(
/\.game-resource-canvas-content\s*\{[^}]*width:\s*100%[^}]*min-width:\s*max\(100%, 620px\)/s,
);
expect(styles).toMatch(
/\.game-resource-section\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)[^}]*height:\s*var\(--resource-section-height\)[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-resource-section-viewport\s*\{[^}]*overflow:\s*auto[^}]*overscroll-behavior:\s*contain/s,
);
expect(styles).not.toMatch(
/\.game-resource-section\s*\{[^}]*position:\s*absolute/s,
);
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
const manifest = createGameCreationAppManifest(
'workbench-runnable',
'可运行工作台',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'http://127.0.0.1:4173',
port: 4173,
};
manifest.assets.push({
id: 'hero-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: {
kind: 'generated',
taskId: 'art-asset-plan',
},
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_image_preview') {
expect(args).toEqual({
projectPath: '/tmp/workbench-runnable',
relativePath: 'assets/hero.png',
});
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 12,
dataUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '可运行工作台',
projectPath: '/tmp/workbench-runnable',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
fireEvent.click(runTab);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const previewFrame = screen.getByTitle(
'可运行工作台 游戏运行画面',
) as HTMLIFrameElement;
expect(previewFrame.getAttribute('src')).toBe('http://127.0.0.1:4173/');
expect(previewFrame.getAttribute('sandbox')).toBe(
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.getByLabelText('测试切片控件')).not.toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
target: { value: 'hero.png' },
});
fireEvent.click(screen.getByRole('button', { name: /hero\.png/ }));
const heroDetails = screen.getByRole('region', { name: 'hero.png' });
expect(within(heroDetails).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroDetails).getByText('image/png')).not.toBeNull();
expect(within(heroDetails).getByText('引用上游')).not.toBeNull();
expect(heroDetails.querySelector('img')).toBeNull();
expect(screen.queryByRole('img', { name: 'hero.png 图片预览' })).toBeNull();
});
it('marks an unvalidated UI prototype as a candidate image', () => {
const manifest = createGameCreationAppManifest(
'workbench-ui-candidate',
'候选界面图测试',
);
manifest.assets.push({
id: 'ui-prototype-candidate',
kind: 'ui-prototype',
mediaType: 'image/png',
localPath: 'assets/ui-prototype.png',
source: {
kind: 'canvas',
taskId: 'design-foundation',
},
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '候选界面图测试',
projectPath: '/tmp/workbench-ui-candidate',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
agentRuntimeSummaries: [
{
group: 'art',
label: '美术 Agent',
status: 'completed',
statusLabel: '已完成',
currentTask: '本轮工作已完成',
currentAction: null,
waitingOn: null,
completedCount: 4,
totalCount: 4,
},
],
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const candidate = screen.getByRole('button', {
name: /ui-prototype\.png(待视觉验收)/,
});
const candidateCard = candidate.closest('.game-resource-card');
expect(candidateCard?.textContent).not.toContain('画板 · 候选界面图');
expect(candidateCard?.textContent).not.toContain('assets/ui-prototype.png');
expect(screen.getByText('仅完成计划')).not.toBeNull();
expect(
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
});
it('refuses to embed a non-loopback game preview in the client workbench', () => {
const manifest = createGameCreationAppManifest(
'workbench-remote-preview',
'远程预览拒绝测试',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'https://example.com/game',
port: 443,
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: '远程预览拒绝测试',
projectPath: '/tmp/workbench-remote-preview',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
});
}
export function registerUserSurfaceBoundaryTests() {
it('keeps the user surface to chat, upload, config and command confirmation', async () => {
renderAppAt('/?main');
expect(screen.getByLabelText('聊天')).not.toBeNull();
expect(screen.getByLabelText('Agent 状态')).not.toBeNull();
const composerInput = screen.getByLabelText('创作想法');
expect(composerInput).not.toBeNull();
expect(screen.getByText('上传')).not.toBeNull();
expect(screen.getByRole('button', { name: '命令' })).not.toBeNull();
expect(screen.getByRole('button', { name: '能力' })).not.toBeNull();
expect(screen.getByRole('button', { name: '配置' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'LLM状态' })).not.toBeNull();
expect(screen.getByRole('button', { name: '显示目录' })).not.toBeNull();
expect(screen.queryByLabelText('项目摘要')).toBeNull();
expect(
(screen.getByRole('button', { name: '灵感草稿' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: '项目状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '权限' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '审计' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '运行' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '资产' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '打开画板' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(
screen.getByRole('button', {
name: '导入画板资产',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '任务' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '索引确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '资产登记确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '记忆写入确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '预览确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '打开预览确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '停止预览确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: 'Agent确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '读对话确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '存对话确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: 'Trace' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '文件' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '索引' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '记忆' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '短期记忆' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '记到黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '覆盖黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '清空黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '快照' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '快照列表' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '历史' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '白名单' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: '静态自检' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '启动预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '打开预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '预览状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '停止预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '刷新状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '刷新 Agent' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '终止' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '重试' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '继续' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '继续说明' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '输出' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '活动' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '上下文包' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: /拆解创作方向/,
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '打开画板' }));
expect(composerInput).toHaveProperty('value', '/canvas ');
fireEvent.click(screen.getByRole('button', { name: '灵感草稿' }));
expect(composerInput).toHaveProperty(
'value',
'像素风厨房弹幕小游戏:玩家用方向键躲避飞来的食材,收集调料加分,60 秒内尽量高分,失败后可一键重开。',
);
expect(screen.queryByText('game.generate_draft')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '白名单' }));
expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull();
expect(screen.getByRole('button', { name: '切换项目' })).not.toBeNull();
expect(screen.getByText('想做什么游戏?')).not.toBeNull();
expect(screen.getAllByText('暂无最近运行证据').length).toBeGreaterThan(0);
expect(screen.queryByLabelText('工作区管理')).toBeNull();
expect(screen.queryByLabelText('开发环境')).toBeNull();
expect(screen.queryByLabelText('运行时配置')).toBeNull();
expect(screen.queryByText('Agent 能力')).toBeNull();
expect(screen.queryByText('编排 Trace')).toBeNull();
});
}
export function registerProjectSupervisorSurfaceTests() {
it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => {
const projectPath = '/tmp/launcher-empty-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-empty-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-empty-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
const messageList =
within(supervisorSurface).getByLabelText('项目总控消息');
expect(
await within(messageList).findByText('想做什么游戏?'),
).not.toBeNull();
expect(
within(supervisorSurface).getByLabelText('项目总控 Agent 状态'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'),
).not.toBeNull();
expect(
within(supervisorSurface).getByRole('button', { name: '发送' }),
).toHaveProperty('disabled', false);
});
it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => {
const projectPath = '/tmp/launcher-reconciliation-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-supervisor-game',
);
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'persisted-supervisor-session',
runId: 'persisted-reconciliation-run',
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '帮我生成一个贪吃蛇',
currentGoal: '完成贪吃蛇原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后继续或取消',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const cancelledQueue = {
total: 2,
pending: 1,
running: 0,
waitingForConfirmation: 0,
waitingForUserInput: 0,
cancelled: 1,
completed: 0,
failed: 0,
latestRunId: 'persisted-reconciliation-run',
updatedAt: 8000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'cancel_game_creator_agent_runtime_task') {
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
status: 'cancelled',
phase: 'cancelled',
currentAction: '待核对的旧任务已结束',
waitingOn: '队列中的下一个任务',
error: null,
taskQueue: cancelledQueue,
updatedAt: 8000,
});
return {
...supervisorHarness.runtimeResult(state),
taskQueue: cancelledQueue,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
expect(
await within(supervisorSurface).findByText('项目总控 Agent · 失败'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('当前阶段:待核对'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
projectPath,
});
const reconcileButton = within(supervisorSurface).getByRole('button', {
name: '已核对,结束旧任务',
});
fireEvent.click(reconcileButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId: 'persisted-reconciliation-run',
},
);
});
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
expect(
await within(supervisorSurface).findByText(
'旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理',
),
).not.toBeNull();
expect(
within(supervisorSurface).queryByRole('button', {
name: '在当前项目重试总控',
}),
).toBeNull();
});
it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => {
const projectPath = '/tmp/launcher-reconciliation-empty-queue';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-empty-queue',
);
const runId = 'reconciliation-empty-queue-run';
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'reconciliation-empty-queue-session',
runId,
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '生成贪吃蛇原型',
currentGoal: '完成可玩原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后结束旧任务',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const emptyQueue = {
total: 1,
pending: 0,
running: 0,
waitingForConfirmation: 0,
waitingForUserInput: 0,
cancelled: 1,
completed: 0,
failed: 0,
latestRunId: runId,
updatedAt: 8000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId: 'reconciliation-empty-queue-session',
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-empty-queue',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'cancel_game_creator_agent_runtime_task') {
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
status: 'cancelled',
phase: 'cancelled',
currentAction: '待核对的旧任务已结束',
waitingOn: '',
error: null,
taskQueue: emptyQueue,
updatedAt: 8000,
});
return {
...supervisorHarness.runtimeResult(state),
taskQueue: emptyQueue,
};
}
if (command === 'confirm_retry_game_creator_agent_runtime_task') {
const nextRunId = String(args?.nextRunId ?? '');
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
runId: nextRunId,
status: 'running',
phase: 'planning',
currentAction: '重新生成项目总控计划',
waitingOn: 'Agent 输出计划或回复',
error: null,
updatedAt: 9000,
});
return {
...supervisorHarness.runtimeResult(state),
acceptedRunId: nextRunId,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
fireEvent.click(
await within(supervisorSurface).findByRole('button', {
name: '已核对,结束旧任务',
}),
);
expect(
await within(supervisorSurface).findByText(
'旧任务已结束,当前队列为空,可重新启动项目总控',
),
).not.toBeNull();
const retryButton = await within(supervisorSurface).findByRole('button', {
name: '在当前项目重试总控',
});
fireEvent.click(retryButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
nextRunId: expect.stringMatching(/^project-supervisor-retry-/),
},
);
});
const cancelCallIndex = invoke.mock.calls.findIndex(
([command]) => command === 'cancel_game_creator_agent_runtime_task',
);
const retryCallIndex = invoke.mock.calls.findIndex(
([command]) =>
command === 'confirm_retry_game_creator_agent_runtime_task',
);
expect(cancelCallIndex).toBeGreaterThanOrEqual(0);
expect(retryCallIndex).toBeGreaterThan(cancelCallIndex);
});
it('starts the game-chat surface as project-free chat with project and settings controls', () => {
render(
React.createElement(App, {
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = screen.getByLabelText('游戏创作聊天');
expect(within(surface).getByText('未选择项目')).not.toBeNull();
expect(within(surface).getByLabelText('最新状态')).not.toBeNull();
expect(within(surface).getByText('请选择项目目录')).not.toBeNull();
expect(
within(surface).getByRole('button', { name: '选择项目路径' }),
).not.toBeNull();
expect(
within(surface).getByRole('button', { name: '设置' }),
).not.toBeNull();
expect(
(
within(surface).getByLabelText(
'项目总控对话内容',
) as HTMLTextAreaElement
).disabled,
).toBe(true);
expect(screen.queryByLabelText('游戏运行')).toBeNull();
expect(document.querySelector('iframe')).toBeNull();
expect(document.querySelector('.game-chat-shell')?.className).toBe(
'game-chat-shell',
);
});
it('exposes External Editor credentials from the game-chat settings', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-creator.config.json',
config: {
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: 'game-chat-editor-secret',
},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderGameChatStatus({
runtime: gameChatRuntimeState(),
viewOverrides: {
onOpenRuntimeConfig: vi.fn(),
runtimeConfigOpen: true,
},
});
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty(
'value',
'http://127.0.0.1:8082',
);
expect(screen.getByLabelText('External Editor API Key')).toHaveProperty(
'type',
'password',
);
expect(
screen
.getByLabelText('External Editor API Key')
.getAttribute('autocomplete'),
).toBe('off');
});
it('opens the dedicated game-chat release without platform authentication', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new Error('platform api-server unavailable'));
render(React.createElement(GameChatReleaseApp));
await act(async () => {
await Promise.resolve();
});
expect(screen.getByLabelText('游戏创作聊天')).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('submits one initial game-chat message per page across StrictMode remounts and terminal updates', async () => {
const projectPath = '/tmp/game-chat-initial-message-latch';
const prompt = '继续完成当前贪吃蛇';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const manifest = createGameCreationAppManifest(
'game-chat-initial-message-latch',
'初始消息闩锁',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '初始消息闩锁',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const renderInitialMessageApp = (message: string) =>
React.createElement(
React.StrictMode,
null,
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
initialSupervisorMessage: message,
}),
);
const rendered = render(renderInitialMessageApp(prompt));
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
});
const firstRunId = String(
invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
)?.[1]?.runId ?? '',
);
expect(firstRunId).not.toBe('');
for (const [index, status] of [
'cancelled',
'failed',
'completed',
].entries()) {
act(() => {
harness.emitRuntime(
harness.runtimeState({
runId: firstRunId,
status,
phase: status,
updatedAt: 4000 + index,
}),
);
});
rendered.rerender(renderInitialMessageApp(`${prompt}-${status}`));
}
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
const initCallCount = invoke.mock.calls.filter(
([command]) => command === 'init_local_game_project',
).length;
rendered.unmount();
render(renderInitialMessageApp(prompt));
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'init_local_game_project',
).length,
).toBeGreaterThan(initCallCount);
expect(
(screen.getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
.disabled,
).toBe(false);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
});
it('removes the initial game-chat message from the launch URL before a page reload', () => {
const searchParams = new URLSearchParams(
'game-chat=&projectPath=%2Ftmp%2Ftcs&initialMessage=%E7%BB%A7%E7%BB%AD%E8%B4%AA%E5%90%83%E8%9B%87',
);
let replacedUrl = '';
expect(
consumeInitialGameChatMessage(
searchParams,
{ pathname: '/index.html', hash: '#game-chat' },
(url) => {
replacedUrl = url;
},
),
).toBe('继续贪吃蛇');
expect(searchParams.has('initialMessage')).toBe(false);
expect(replacedUrl).not.toContain('initialMessage');
expect(replacedUrl).toContain('projectPath=%2Ftmp%2Ftcs');
expect(
consumeInitialGameChatMessage(
searchParams,
{ pathname: '/index.html', hash: '#game-chat' },
() => {
throw new Error('重载后不应再次改写 URL');
},
),
).toBe('');
});
it('does not deliver a launch message when the opened game-chat project changed', async () => {
const launchProjectPath = '/tmp/game-chat-initial-project';
const openedProjectPath = '/tmp/game-chat-replacement-project';
const prompt = '这条消息只能投递给启动项目';
const harness = createProjectSupervisorRuntimeHarness({
projectPath: openedProjectPath,
});
const manifest = createGameCreationAppManifest(
'game-chat-replacement-project',
'替换项目',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath: launchProjectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '启动项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath: openedProjectPath,
manifestPath: `${openedProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: launchProjectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
initialSupervisorMessage: prompt,
}),
);
await waitFor(() => {
expect(
(screen.getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
.disabled,
).toBe(false);
expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', {
projectPath: openedProjectPath,
});
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
expect(screen.queryByText(prompt)).toBeNull();
});
it('keeps the non-empty folder confirmation when game-chat initializes a picked project', async () => {
const projectPath = '/tmp/game-chat-non-empty';
const initialSupervisorMessage = '不要在确认前启动这一轮';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return projectPath;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: false,
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'is_local_project_directory_non_empty') {
return true;
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
projectSupervisorOnly: true,
gameChatOnly: true,
initialSupervisorMessage,
}),
);
fireEvent.click(screen.getByRole('button', { name: '选择项目路径' }));
const dialog = await screen.findByRole('dialog', {
name: '文件夹不是空的',
});
expect(within(dialog).getByText(projectPath)).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'init_local_game_project',
expect.anything(),
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
fireEvent.click(within(dialog).getByRole('button', { name: '继续新建' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath,
projectId: 'local-project-draft',
name: 'game-chat-non-empty',
});
});
expect(await screen.findByText('game-chat-non-empty')).not.toBeNull();
});
it('embeds only a running loopback preview in the game-chat surface with the shared sandbox', async () => {
const projectPath = '/tmp/game-chat-loopback';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-loopback',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'running',
url: 'http://127.0.0.1:4173',
port: 4173,
root: `${projectPath}/game`,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const preview = await screen.findByLabelText('游戏运行');
const iframe = within(preview).getByTitle(
'game-chat-loopback 游戏运行画面',
);
expect(iframe.getAttribute('src')).toBe('http://127.0.0.1:4173/');
expect(iframe.getAttribute('sandbox')).toBe(
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(iframe.getAttribute('allow')).toBe('autoplay; fullscreen; gamepad');
expect(
document.querySelector('.game-chat-shell.has-preview'),
).not.toBeNull();
});
it('routes game-chat /preview through the built-in confirmation flow instead of starting an autonomous run', async () => {
const projectPath = '/tmp/game-chat-manual-preview';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
let previewStarted = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-manual-preview',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4310',
port: 4310,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['preview.start'],
},
};
}
if (command === 'start_local_game_preview') {
previewStarted = true;
return {
status: 'running',
url: 'http://127.0.0.1:4310',
port: 4310,
root: `${projectPath}/game`,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, { target: { value: '/preview' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
const commandConfirmation =
await within(surface).findByLabelText('项目总控 Agent 待确认命令');
expect(
within(commandConfirmation).getByText('preview.start'),
).not.toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toBe(false);
fireEvent.click(
within(commandConfirmation).getByRole('button', { name: '确认' }),
);
const policyConfirmation =
await within(surface).findByLabelText('项目总控 Agent 待确认操作');
fireEvent.click(
within(policyConfirmation).getByRole('button', { name: '确认' }),
);
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(
invoke.mock.calls.some(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toBe(false);
});
it('removes the game-chat game region after the registered preview stops', async () => {
const projectPath = '/tmp/game-chat-stopped-preview';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
let previewStatusReads = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-stopped-preview',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
previewStatusReads += 1;
return previewStatusReads === 1
? {
status: 'running',
url: 'http://127.0.0.1:4174',
port: 4174,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
await waitFor(
() => {
expect(screen.queryByLabelText('游戏运行')).toBeNull();
expect(
document.querySelector('.game-chat-shell.has-preview'),
).toBeNull();
},
{ timeout: 2500 },
);
});
it('does not render a game region for a running remote preview URL', async () => {
const projectPath = '/tmp/game-chat-remote-preview';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-remote-preview',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'running',
url: 'https://preview.example.com/game',
port: 443,
root: `${projectPath}/game`,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', {
projectPath,
});
});
expect(await screen.findByLabelText('游戏创作聊天')).not.toBeNull();
expect(screen.queryByLabelText('游戏运行')).toBeNull();
expect(document.querySelector('iframe')).toBeNull();
expect(document.querySelector('.game-chat-shell.has-preview')).toBeNull();
});
it('filters game-chat events to the active parent run, sorts and deduplicates them', () => {
const duplicate = gameChatRuntimeEvent({
runId: 'active-parent-run',
summary: '总控事件 2',
updatedAt: 20,
});
const supervisor = gameChatRuntimeState({
runId: 'active-parent-run',
source: 'project-supervisor-game-chat',
recentEvents: [
gameChatRuntimeEvent({
runId: 'active-parent-run',
summary: '总控事件 1',
updatedAt: 10,
}),
gameChatRuntimeEvent({
runId: 'previous-parent-run',
summary: '总控旧 run 事件',
updatedAt: 50,
}),
duplicate,
duplicate,
],
});
const currentChild = gameChatRuntimeState({
agentId: 'design-foundation',
taskId: 'design-foundation',
sessionId: 'design-session',
runId: 'design-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: 'active-parent-run',
updatedAt: 30,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'design-foundation',
runId: 'design-run',
summary: '当前子 Agent 事件',
updatedAt: 30,
}),
gameChatRuntimeEvent({
agentId: 'design-foundation',
runId: 'previous-design-run',
summary: '子 Agent 旧 run 事件',
updatedAt: 60,
}),
],
});
const staleChild = gameChatRuntimeState({
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'art-session',
runId: 'art-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: 'stale-parent-run',
updatedAt: 40,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'art-asset-plan',
runId: 'art-run',
summary: '过期父 run 事件',
updatedAt: 40,
}),
],
});
expect(supervisor.recentEvents?.map((event) => event.summary)).toEqual([
'总控事件 1',
'总控旧 run 事件',
'总控事件 2',
'总控事件 2',
]);
const events = collectGameChatRuntimeEvents(supervisor, {
'design-foundation': currentChild,
'art-asset-plan': staleChild,
});
expect(events.map((item) => item.event.summary)).toEqual([
'当前子 Agent 事件',
'总控事件 2',
'总控事件 1',
]);
expect(events.map((item) => item.agentLabel)).toEqual([
'玩法策划 Agent',
'项目总控 Agent',
'项目总控 Agent',
]);
});
it('turns user-visible game-chat runtime outputs into chronological chat messages and filters protocol payloads', () => {
const runId = 'game-chat-runtime-message-run';
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.started',
summary: 'Synthetic start event must stay out of chat',
updatedAt: 30,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: 'Root terminal event is covered by durable public status',
updatedAt: 35,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.progress',
summary: 'Generated prototype progress',
detail: 'internal loop iteration 4',
updatedAt: 40,
}),
gameChatRuntimeEvent({
runId,
eventType: 'tool.request',
summary: 'agent.runtime.tool.request',
detail: '{"tool":"agent.delegate","arguments":{"secret":"x"}}',
publicText: null,
updatedAt: 50,
}),
gameChatRuntimeEvent({
runId,
eventType: 'action',
summary: 'call tool agent.delegate',
detail: 'code agent repair collision',
publicText: 'code agent repair collision',
updatedAt: 60,
}),
gameChatRuntimeEvent({
runId,
eventType: 'observation',
summary: 'command.output_read:ok',
detail: '{"output":"private process output"}',
publicText: null,
updatedAt: 70,
}),
gameChatRuntimeEvent({
runId,
eventType: 'observation',
summary: 'preview.validate:ok',
detail: '{"passed":true,"revision":3}',
updatedAt: 80,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.progress',
summary: 'legacy output without stable event identity',
eventId: '',
publicText: 'legacy output without stable event identity',
updatedAt: 90,
}),
],
});
const designRuntime = gameChatRuntimeState({
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'design-director-session',
runId: 'design-director-child-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'design-director',
runId: 'design-director-child-run',
eventType: 'turn.started',
summary: 'Design Agent started and must stay visible',
updatedAt: 32,
}),
],
updatedAt: 32,
});
const messages = gameChatRuntimeEventMessages(runtime, {
'design-director': designRuntime,
});
expect(messages.map((message) => message.updatedAt)).toEqual([
32, 40, 60, 80,
]);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('Design Agent started and must stay visible'),
expect.stringContaining('Generated prototype progress'),
expect.stringContaining('code agent repair collision'),
expect.stringContaining('preview.validate:ok'),
]);
expect(messages.every((message) => message.role === 'assistant')).toBe(
true,
);
expect(
messages.every((message) =>
message.messageId?.startsWith('game-chat-runtime-event:'),
),
).toBe(true);
expect(messages.map((message) => message.text).join('\n')).not.toContain(
'private process output',
);
expect(messages.map((message) => message.text).join('\n')).not.toContain(
'agent.runtime.tool.request',
);
for (const continuation of [
{ kind: 'receipt', source: 'agent-delegate-receipt' },
{ kind: 'isolated join', source: 'agent-isolated-join' },
]) {
const continuationRunId = `supervisor-${continuation.kind.replaceAll(' ', '-')}-continuation`;
const supervisorContinuation = gameChatRuntimeState({
runId: continuationRunId,
source: continuation.source,
parentAgentId: null,
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.started',
summary: `Supervisor ${continuation.kind} continuation started`,
updatedAt: 100,
}),
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: `Supervisor ${continuation.kind} continuation failed`,
updatedAt: 110,
}),
],
});
expect(
gameChatRuntimeEventMessages(supervisorContinuation, {}).map(
(message) => message.text,
),
).toEqual([
expect.stringContaining(
`Supervisor ${continuation.kind} continuation started`,
),
expect.stringContaining(
`Supervisor ${continuation.kind} continuation failed`,
),
]);
}
});
it('turns only professional final-reply streams into labeled game-chat messages', () => {
const makeStream = (
agentId: string,
status: 'ready' | 'committed',
accumulatedText: string,
responseRevision = 1,
) => ({
schemaVersion: 'game-creator-runtime-response-stream.v1',
agentId,
taskId: agentId,
sessionId: 'supervisor-session-active',
runId: 'game-chat-final-reply-run',
requestKind: 'final-reply',
requestSlot: `final-reply-loop-1-revision-${responseRevision}`,
appliedSteerCursor: 0,
responseRevision,
sequence: 2,
status,
accumulatedText,
finishReason: 'stop',
startedAt: 100,
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', '试玩验证已完成'),
{
...makeStream('code-prototype', 'ready', 'tool plan should be hidden'),
requestKind: 'tool-plan',
},
]);
expect(messages).toHaveLength(7);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('玩法方向已完成'),
expect.stringContaining('视觉方向已完成'),
expect.stringContaining('平台美术图集已生成并登记'),
expect.stringContaining('程序方案已完成'),
expect.stringContaining('代码原型已完成'),
expect.stringContaining('预览就绪检查已完成'),
expect.stringContaining('试玩验证已完成'),
]);
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);
});
it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => {
const projectPath = '/tmp/game-chat-final-reply-hydration';
const runId = 'game-chat-final-reply-hydration-run';
const rootRuntime = gameChatRuntimeState({
sessionId: 'supervisor-session-active',
runId,
status: 'running',
phase: 'execution',
updatedAt: 100,
});
const makeRuntimeResult = (
agentId: string,
status: 'ready' | 'committed',
text: string,
updatedAt: number,
) => {
const state = gameChatRuntimeState({
agentId,
taskId: agentId,
sessionId: 'supervisor-session-active',
runId: `${agentId}-${runId}`,
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: runId,
status: 'completed',
phase: 'completed',
updatedAt,
});
return {
state,
sessionPath: `${projectPath}/.agent/runtime/${agentId}.json`,
eventPath: `${projectPath}/.agent/runtime/${agentId}.jsonl`,
responseStream: {
schemaVersion: 'game-creator-runtime-response-stream.v1',
agentId,
taskId: agentId,
sessionId: 'supervisor-session-active',
runId: state.runId,
requestKind: 'final-reply',
requestSlot: 'final-reply-loop-1-revision-1',
appliedSteerCursor: 0,
responseRevision: 1,
sequence: 2,
status,
accumulatedText: text,
finishReason: 'stop',
startedAt: updatedAt - 20,
updatedAt,
},
};
};
const professionalResults = [
makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170),
makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180),
makeRuntimeResult(
'art-asset-plan',
'committed',
'平台美术图集已生成并登记',
190,
),
makeRuntimeResult('code-director', 'ready', '程序方案已完成', 195),
makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200),
makeRuntimeResult(
'preview-readiness',
'committed',
'预览就绪已完成',
210,
),
makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220),
];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: rootRuntime,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-final-reply-hydration',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'read_game_creator_agent_runtimes') {
return professionalResults;
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const renderRelease = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
let rendered = renderRelease();
await waitFor(() => {
expect(screen.getByText(/视觉方向已完成/)).not.toBeNull();
expect(screen.getByText(/平台美术图集已生成并登记/)).not.toBeNull();
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
expect(screen.getByText(/预览就绪已完成/)).not.toBeNull();
expect(screen.getByText(/试玩验证已完成/)).not.toBeNull();
});
const finalReplyAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
String(args?.messageId ?? '').startsWith('game-chat-final-reply:'),
);
await waitFor(() => {
expect(finalReplyAppends()).toHaveLength(7);
});
rendered.unmount();
rendered = renderRelease();
await waitFor(() => {
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
});
expect(finalReplyAppends()).toHaveLength(7);
rendered.unmount();
});
it('persists game-chat runtime event messages with stable ids and does not duplicate them after hydration', async () => {
const projectPath = '/tmp/game-chat-runtime-message-hydration';
const runId = 'game-chat-runtime-message-hydration-run';
const events = [
gameChatRuntimeEvent({
sessionId: 'supervisor-session-active',
runId,
eventType: 'turn.progress',
summary: 'First visible runtime output',
updatedAt: 40,
}),
gameChatRuntimeEvent({
sessionId: 'supervisor-session-active',
runId,
eventType: 'observation',
summary: 'Second visible runtime output',
updatedAt: 50,
}),
];
const manifest = createGameCreationAppManifest(
'game-chat-runtime-message-hydration',
'game-chat-runtime-message-hydration',
);
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: {
sessionId: 'supervisor-session-active',
runId,
status: 'running',
phase: 'execution',
recentEvents: events,
updatedAt: 60,
},
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const renderApp = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
let rendered = renderApp();
const runtimeEventAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(2);
});
const firstRuntimeMessage = screen
.getByText(/First visible runtime output/)
.closest('p');
const secondRuntimeMessage = screen
.getByText(/Second visible runtime output/)
.closest('p');
expect(
firstRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
).toBe('1970-01-01T00:00:40.000Z');
expect(firstRuntimeMessage?.querySelector('time')?.textContent).toMatch(
/^\d{2}:\d{2}:\d{2}$/u,
);
expect(
secondRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
).toBe('1970-01-01T00:00:50.000Z');
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
rendered.unmount();
rendered = renderApp();
await waitFor(() => {
expect(
screen.getByText(/First visible runtime output/).closest('p'),
).not.toBeNull();
expect(
screen.getByText(/Second visible runtime output/).closest('p'),
).not.toBeNull();
});
expect(runtimeEventAppends()).toHaveLength(2);
rendered.unmount();
});
it('keeps an unstructured image inspection neutral instead of presenting tool success as visual approval', () => {
const runId = 'game-chat-unstructured-image-inspection';
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'ok',
phase: 'tool-observation',
summary: 'image.inspectok · 视觉检查已完成,共分析 2 张图片',
detail: JSON.stringify({
inspectionKind: null,
validationProfile: null,
passed: null,
checks: null,
issues: null,
}),
updatedAt: 25,
}),
],
});
const progress = buildGameChatProgressEvidence(runtime, {}, null);
expect(progress?.evidence).toEqual([
expect.objectContaining({
label: '视觉分析完成',
tone: 'work',
}),
]);
});
it('presents an unstructured image inspection tool failure from its production summary', () => {
const runId = 'game-chat-unstructured-image-inspection-failure';
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'running',
phase: 'tool-observation',
summary: 'image.inspectfailed · 视觉模型调用失败',
detail: null,
updatedAt: 26,
}),
],
});
const progress = buildGameChatProgressEvidence(runtime, {}, null);
expect(progress?.evidence).toEqual([
expect.objectContaining({
label: '视觉分析未完成',
tone: 'fail',
}),
]);
});
it('uses structured image inspection verdicts for pass and fail presentation', () => {
for (const [passed, label, tone] of [
[true, '视觉检查通过', 'pass'],
[false, '视觉检查未通过', 'fail'],
] as const) {
const runId = `game-chat-structured-image-inspection-${passed}`;
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: passed ? 'ok' : 'failed',
phase: 'tool-observation',
summary: `image.inspect${passed ? 'ok' : 'failed'} · UI 原型视觉检查${passed ? '已通过' : '未通过'}`,
detail: JSON.stringify({ passed }),
updatedAt: passed ? 26 : 27,
}),
],
});
expect(
buildGameChatProgressEvidence(runtime, {}, null)?.evidence,
).toEqual([expect.objectContaining({ label, tone })]);
}
});
it('requires an explicit playtest pass before presenting preview validation as passed', () => {
const runId = 'game-chat-preview-without-playtest-pass';
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'ok',
phase: 'tool-observation',
summary: 'preview.validateok · 浏览器验证已完成',
detail: JSON.stringify({
diagnostics: [],
diagnosticsCount: 0,
passed: true,
revision: 8,
}),
updatedAt: 26,
}),
],
});
const progress = buildGameChatProgressEvidence(runtime, {}, null);
expect(progress?.evidence).toEqual([
expect.objectContaining({
label: '试玩未通过',
tone: 'fail',
}),
]);
});
it('does not infer a preview pass from an ok summary when structured evidence is missing', () => {
const runId = 'game-chat-preview-without-structured-evidence';
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'ok',
phase: 'tool-observation',
summary: 'preview.validateok · 浏览器验证已完成',
detail: null,
updatedAt: 27,
}),
],
});
const progress = buildGameChatProgressEvidence(runtime, {}, null);
expect(progress?.evidence).toEqual([
expect.objectContaining({
label: '试玩未通过',
tone: 'fail',
}),
]);
});
it('derives a playable revision only from a passed preview validation with a positive integer revision', () => {
const runId = 'game-chat-playable-revision-run';
const invalidPlaytest = gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 1,
updatedAt: 3000,
});
const invalidEvidence = [
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
eventType: 'observation',
summary: 'preview.validatefailed',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 8,
}),
updatedAt: 8000,
}),
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
eventType: 'observation',
summary: 'preview.validateok · 验证未通过',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 7,
}),
updatedAt: 7000,
}),
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
eventType: 'observation',
summary: 'preview.validateok · 缺少 revision',
detail: JSON.stringify({ passed: true, playtestPassed: true }),
updatedAt: 6000,
}),
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
eventType: 'observation',
summary: 'preview.validateok · revision 非正整数',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 0,
}),
updatedAt: 5000,
}),
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
eventType: 'observation',
summary: 'preview.validateok · revision 不是整数',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 1.5,
}),
updatedAt: 4000,
}),
];
invalidPlaytest.recentEvents = invalidEvidence;
const runtimeWithOnlyInvalidEvidence = gameChatRuntimeState({
runId,
});
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest': invalidPlaytest,
}),
).toBeNull();
const previewPlaytest = gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 9,
updatedAt: 9000,
});
previewPlaytest.recentEvents?.push(
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: previewPlaytest.sessionId,
runId: previewPlaytest.runId,
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validateok · revision 10 首次验证',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 10,
}),
updatedAt: 8500,
}),
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: previewPlaytest.sessionId,
runId: previewPlaytest.runId,
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validateok · revision 10 最新验证',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 10,
}),
updatedAt: 8800,
}),
);
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest': previewPlaytest,
}),
).toEqual({ runId, revision: 10, validatedAt: 8800 });
const staleParentPlaytest = gameChatPreviewPlaytestRuntime({
parentRunId: 'different-parent-run',
revision: 11,
updatedAt: 11000,
});
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest': staleParentPlaytest,
}),
).toBeNull();
const wrongSourcePlaytest = {
...gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 12,
updatedAt: 12000,
}),
source: 'agent-delegate' as const,
};
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest': wrongSourcePlaytest,
}),
).toBeNull();
const passedRevision = gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 20,
updatedAt: 20000,
});
const higherFailedRevision = gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 21,
updatedAt: 21000,
});
higherFailedRevision.recentEvents = [
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: higherFailedRevision.sessionId,
runId: higherFailedRevision.runId,
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validatefailed · revision 21',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 21,
}),
updatedAt: 21000,
}),
];
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest-passed': passedRevision,
'preview-playtest-higher-failed': higherFailedRevision,
}),
).toBeNull();
const sameRevisionLaterFailed = gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision: 22,
updatedAt: 22000,
});
sameRevisionLaterFailed.recentEvents?.push(
gameChatRuntimeEvent({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: sameRevisionLaterFailed.sessionId,
runId: sameRevisionLaterFailed.runId,
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validatefailed · revision 22 后续回归',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 22,
}),
updatedAt: 22100,
}),
);
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'preview-playtest': sameRevisionLaterFailed,
}),
).toBeNull();
});
it('keeps game-chat status compact and shows the capped latest twenty events in runtime details', () => {
const runtime = gameChatRuntimeState({
runId: 'game-chat-events-run',
recentEvents: Array.from({ length: 23 }, (_, index) =>
gameChatRuntimeEvent({
runId: 'game-chat-events-run',
summary: `状态事件 ${index + 1}`,
updatedAt: index + 1,
}),
),
});
expect(runtime.recentEvents).toHaveLength(23);
renderGameChatStatus({ runtime });
const statusCard = screen.getByLabelText('最新状态');
expect(statusCard.textContent).not.toContain('状态事件 23');
expect(statusCard.textContent).not.toContain('疑似停滞');
expect(statusCard.textContent).not.toContain('已持续');
expect(statusCard.textContent).toContain('运行详情');
fireEvent.click(
within(statusCard).getByRole('button', { name: '运行详情' }),
);
const details = screen.getByRole('dialog', { name: '运行详情' });
const eventList = within(details).getByLabelText('最近运行活动');
expect(eventList.querySelectorAll(':scope > div')).toHaveLength(20);
const expandedEventTexts = Array.from(
eventList.querySelectorAll(':scope > div'),
(element) => element.textContent,
);
expect(expandedEventTexts[0]).toContain('状态事件 23');
expect(
expandedEventTexts.some((text) => text?.includes('状态事件 4')),
).toBe(true);
expect(
expandedEventTexts.some((text) => text?.includes('状态事件 3')),
).toBe(false);
expect(eventList.querySelectorAll('time')).toHaveLength(20);
});
it('keeps three stage records in the scrollable lane with the newest messages and timestamps visible after them', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-chat-conversation\.supervisor-chat-only-shell\s*\{[^}]*grid-template-rows:\s*64px auto minmax\(0, 1fr\) auto/s,
);
expect(styles).toMatch(
/\.supervisor-chat-only-message-list\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/s,
);
const updatedAt = Date.UTC(2026, 7, 5, 2, 55, 3);
renderGameChatStatus({
runtime: gameChatRuntimeState({ updatedAt }),
viewOverrides: {
visibleMessages: [
...Array.from({ length: 3 }, (_, index) => ({
role: 'assistant' as const,
text: `【Supervisor 阶段记录】\n阶段 ${index + 1}`,
messageId: `stage-${index + 1}`,
updatedAt: updatedAt + index,
})),
{
role: 'assistant',
text: '这是阶段卡片之后的最新持久消息',
messageId: 'latest-persisted',
updatedAt: updatedAt + 3,
},
],
transientReply: '这是当前仍在流式输出的新消息',
transientReplyUpdatedAt: updatedAt + 4,
},
});
const messageList = screen.getByLabelText('项目总控消息');
const messages = Array.from(
messageList.querySelectorAll<HTMLElement>(':scope > .message'),
);
expect(messages).toHaveLength(5);
expect(
messages
.slice(0, 3)
.every((message) =>
message.classList.contains('game-chat-stage-record'),
),
).toBe(true);
expect(messages[3]?.textContent).toContain('阶段卡片之后的最新持久消息');
expect(messages[4]?.textContent).toContain('当前仍在流式输出的新消息');
expect(messages[4]?.getAttribute('aria-label')).toBe(
'项目总控 Agent 实时回复',
);
expect(messages[4]?.querySelector('time')?.getAttribute('datetime')).toBe(
new Date(updatedAt + 4).toISOString(),
);
expect(messageList.querySelectorAll(':scope > .message time')).toHaveLength(
5,
);
});
it('falls back to unknown time for out-of-range message and stream timestamps', () => {
expect(() =>
renderGameChatStatus({
runtime: gameChatRuntimeState({ updatedAt: Number.MAX_VALUE }),
viewOverrides: {
visibleMessages: [
{
role: 'assistant',
text: '带无效时间戳的持久消息',
messageId: 'invalid-time-persisted',
updatedAt: Number.MAX_VALUE,
},
],
transientReply: '带无效时间戳的实时消息',
transientReplyUpdatedAt: Number.MAX_VALUE,
},
}),
).not.toThrow();
const messageTimes = screen
.getByLabelText('项目总控消息')
.querySelectorAll<HTMLElement>(':scope > .message time');
expect(messageTimes).toHaveLength(2);
for (const time of messageTimes) {
expect(time.textContent).toBe('时间未知');
expect(time.hasAttribute('datetime')).toBe(false);
}
});
it('refreshes elapsed time every ten seconds and warns after five minutes without parent or child progress', () => {
vi.useFakeTimers();
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
vi.setSystemTime(now);
try {
const runId = 'game-chat-stalled-runtime-run';
const startedAt = now - 9 * 60 * 1000 - 28 * 1000;
const runtime = gameChatRuntimeState({
runId,
startedAt,
updatedAt: now - 6 * 60 * 1000,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.progress',
summary: '事件窗口内最早进度',
updatedAt: now - 6 * 60 * 1000,
}),
],
});
const childRuntime = gameChatRuntimeState({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'game-chat-stalled-code-session',
runId: 'game-chat-stalled-code-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
updatedAt: now - 5 * 60 * 1000 - 10 * 1000,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'game-chat-stalled-code-session',
runId: 'game-chat-stalled-code-run',
summary: '程序 Agent 最近进度',
updatedAt: now - 4 * 60 * 1000 - 55 * 1000,
}),
],
});
renderGameChatStatus({
runtime,
runtimeByAgentId: { 'code-director': childRuntime },
});
let status = screen.getByLabelText('最新状态');
expect(status.textContent).toContain('运行中');
expect(status.textContent).not.toContain('疑似停滞');
expect(status.textContent).toContain('已持续 9 分 28 秒');
expect(
within(status).getByTitle('最近运行活动时间').textContent,
).toContain('已持续 9 分 28 秒');
act(() => {
vi.advanceTimersByTime(10_000);
});
status = screen.getByLabelText('最新状态');
expect(status.textContent).toContain('运行中 · 疑似停滞');
expect(status.textContent).toContain('已持续 9 分 38 秒');
expect(status.textContent).toContain('5 分 5 秒无新进度');
} finally {
cleanup();
vi.useRealTimers();
}
});
it('does not report expected waits as stalled', () => {
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
vi.spyOn(Date, 'now').mockReturnValue(now);
const runId = 'game-chat-expected-wait-run';
const staleAt = now - 10 * 60 * 1000;
const baseRuntime = gameChatRuntimeState({
runId,
updatedAt: staleAt,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.started',
summary: '本轮开始',
updatedAt: staleAt,
}),
],
});
const pendingToolAction = {
actionId: 'action-1',
actionFingerprint: 'fingerprint-1',
tool: 'file.write',
inputSummary: '写入游戏代码',
reason: '需要确认',
requestedAt: staleAt,
};
const userInputRequest = {
schemaVersion: 'game-creator-agent-user-input.v1',
requestId: 'request-1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: baseRuntime.sessionId,
runId,
actionId: 'action-2',
status: 'pending' as const,
questions: [],
allowFreeform: true,
responseId: null,
requestedAt: staleAt,
updatedAt: staleAt,
};
const cases: Array<{
label: string;
runtime: AgentRuntimeState;
runtimeByAgentId?: Record<string, AgentRuntimeState | undefined>;
viewOverrides?: Partial<
React.ComponentProps<typeof SupervisorChatOnlyView>
>;
}> = [
{
label: 'waiting-for-user-input status',
runtime: { ...baseRuntime, status: 'waiting-for-user-input' },
},
{
label: 'userInputRequest',
runtime: { ...baseRuntime, userInputRequest },
},
{
label: 'waiting-for-confirmation phase',
runtime: { ...baseRuntime, phase: 'waiting-for-confirmation' },
},
{
label: 'pendingToolAction',
runtime: { ...baseRuntime, pendingToolAction },
},
...[
'waiting-for-provider-retry',
'waiting-for-visual-asset',
'waiting-for-process-session',
'waiting-for-delegate-receipts',
'waiting-for-isolated-join',
'waiting-for-manifest-tasks',
'paused',
'pausing',
].map((phase) => ({
label: `${phase} phase`,
runtime: { ...baseRuntime, phase },
})),
{
label: 'pause-requested goal',
runtime: { ...baseRuntime, goalStatus: 'pause-requested' },
},
{
label: 'pendingConfirmation',
runtime: baseRuntime,
viewOverrides: {
pendingConfirmation: {
commandId: 'preview.start',
detail: '启动预览',
projectPath: '/tmp/game-chat-status',
},
},
},
{
label: 'pendingCommand',
runtime: baseRuntime,
viewOverrides: { pendingCommand: { id: 'preview.start' } },
},
{
label: 'child provider retry',
runtime: baseRuntime,
runtimeByAgentId: {
'code-director': gameChatRuntimeState({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'game-chat-waiting-code-session',
runId: 'game-chat-waiting-code-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
phase: 'waiting-for-provider-retry',
updatedAt: staleAt,
}),
},
},
];
for (const candidate of cases) {
renderGameChatStatus(candidate);
const text = screen.getByLabelText('最新状态').textContent ?? '';
expect(text, candidate.label).not.toContain('疑似停滞');
expect(text, candidate.label).not.toContain('无新进度');
cleanup();
}
});
it('still reports a stalled child when another child is in an expected wait', () => {
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
vi.spyOn(Date, 'now').mockReturnValue(now);
const runId = 'game-chat-mixed-stall-run';
const staleAt = now - 10 * 60 * 1000;
const runtime = gameChatRuntimeState({
runId,
updatedAt: staleAt,
});
const waitingChild = gameChatRuntimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'game-chat-waiting-art-session',
runId: 'game-chat-waiting-art-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
phase: 'waiting-for-provider-retry',
updatedAt: now - 10 * 1000,
});
const stalledChild = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'game-chat-stalled-code-session',
runId: 'game-chat-stalled-code-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
updatedAt: staleAt,
});
renderGameChatStatus({
runtime,
runtimeByAgentId: {
'art-director': waitingChild,
'code-prototype': stalledChild,
},
});
const text = screen.getByLabelText('最新状态').textContent ?? '';
expect(text).toContain('运行中 · 疑似停滞');
expect(text).toContain('10 分 0 秒无新进度');
});
it('freezes elapsed time when the run reaches a terminal state', () => {
vi.useFakeTimers();
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
vi.setSystemTime(now);
try {
const runId = 'game-chat-terminal-duration-run';
const runtime = gameChatRuntimeState({
runId,
status: 'completed',
phase: 'completed',
updatedAt: now - 60 * 1000,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.started',
summary: '本轮开始',
updatedAt: now - 10 * 60 * 1000,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.completed',
status: 'completed',
phase: 'completed',
summary: '本轮完成',
updatedAt: now - 60 * 1000,
}),
],
});
const childRuntime = gameChatRuntimeState({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'game-chat-terminal-duration-code-session',
runId: 'game-chat-terminal-duration-code-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
status: 'completed',
phase: 'completed',
updatedAt: now - 30 * 1000,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'game-chat-terminal-duration-code-session',
runId: 'game-chat-terminal-duration-code-run',
eventType: 'turn.completed',
status: 'completed',
phase: 'completed',
summary: '子任务晚于父 Run 收口',
updatedAt: now - 30 * 1000,
}),
],
});
renderGameChatStatus({
runtime,
runtimeByAgentId: { 'code-director': childRuntime },
});
expect(screen.getByLabelText('最新状态').textContent).toContain(
'已持续 9 分 0 秒',
);
act(() => {
vi.advanceTimersByTime(60 * 60 * 1000);
});
expect(screen.getByLabelText('最新状态').textContent).toContain(
'已持续 9 分 0 秒',
);
} finally {
cleanup();
vi.useRealTimers();
}
});
it('hides internal loop iteration wording from game-chat latest status events', () => {
const runtime = gameChatRuntimeState({
runId: 'game-chat-loop-wording-run',
recentEvents: [
gameChatRuntimeEvent({
runId: 'game-chat-loop-wording-run',
eventType: 'turn.progress',
summary: 'Agent 已形成第 8 轮有效进度',
detail: '生成 Agent 工具计划(第 9 轮)',
updatedAt: 9000,
}),
],
});
renderGameChatStatus({ runtime });
expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u);
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
const eventList = screen.getByLabelText('最近运行活动');
expect(eventList.textContent).toContain('Agent 已形成本轮有效进度');
expect(eventList.textContent).toContain('生成 Agent 工具计划(本轮)');
expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u);
});
it('shows and archives the explicit mud point interruption from a failed art child runtime', () => {
const rootRunId = 'game-chat-mud-point-root-run';
const runtime = gameChatRuntimeState({
runId: rootRunId,
status: 'failed',
phase: 'failed',
error: '专业 Agent 执行失败',
updatedAt: 9100,
});
const artRuntime = gameChatRuntimeState({
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'game-chat-mud-point-art-session',
runId: 'game-chat-mud-point-art-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: rootRunId,
status: 'failed',
phase: 'failed',
error:
'平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2operationId=private-operation-id',
updatedAt: 9000,
});
const runtimeByAgentId = { 'art-asset-plan': artRuntime };
renderGameChatStatus({ runtime, runtimeByAgentId });
const status = screen.getByLabelText('最新状态').textContent ?? '';
expect(status).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
expect(status).not.toContain('operationId');
expect(status).not.toContain('private-operation-id');
const progress = buildGameChatProgressEvidence(
runtime,
runtimeByAgentId,
null,
);
if (!progress) {
throw new Error('missing failed game-chat progress fixture');
}
const stageRecord = formatGameChatStageRecord(runtime, progress, []);
expect(stageRecord).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
expect(stageRecord).not.toContain('operationId');
expect(stageRecord).not.toContain('private-operation-id');
});
it('counts only the seven first-playable tasks in game-chat progress', () => {
const manifest = createGameCreationAppManifest(
'game-chat-progress-total',
'game-chat-progress-total',
);
manifest.tasks = manifest.tasks.map((task) =>
isGameChatStageTask(task.id)
? { ...task, status: 'completed' as const }
: task,
);
const runtime = gameChatRuntimeState({
runId: 'game-chat-progress-total-run',
status: 'running',
phase: 'execution',
updatedAt: 9000,
});
renderGameChatStatus({ runtime, manifest });
const status = screen.getByLabelText('最新状态');
expect(status.textContent).toContain('任务图 7/7');
expect(status.textContent).not.toContain('publish-strategy');
expect(status.textContent).not.toContain('publish-package');
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
const progress = screen.getByLabelText('Supervisor 进度播报');
expect(progress.textContent).toContain('任务图 7/7');
});
it('keeps one live progress card while persisting every public game-chat output as a message', async () => {
const projectPath = '/tmp/game-chat-progress-broadcast';
const supervisorRunId = 'game-chat-progress-run';
const previewFailure = gameChatRuntimeEvent({
runId: supervisorRunId,
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validatefailed',
detail: JSON.stringify({
diagnostics: [
{ message: '角色仍会穿过右侧墙体' },
'失败后重开按钮没有响应',
],
diagnosticsCount: 2,
passed: false,
playtestPassed: false,
revision: 7,
}),
updatedAt: 7100,
});
const initialSupervisorOverrides = {
runId: supervisorRunId,
source: 'project-supervisor-game-chat',
runProfile: 'autonomous-game-build' as const,
status: 'running',
phase: 'execution',
loopIteration: 4,
currentTask: '完成首版可运行原型',
currentAction: '核对首版试玩诊断',
planRevision: 1,
planSteps: [
{ index: 0, title: '完成基础玩法', status: 'completed' },
{ index: 1, title: '核对首版试玩诊断', status: 'in_progress' },
{ index: 2, title: '修复并复测', status: 'pending' },
],
activePlanStepIndex: 1,
recentEvents: [previewFailure],
updatedAt: 7200,
};
let professionalRuntimes: Array<Record<string, unknown>> = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: initialSupervisorOverrides,
runtimeMapLoader: async () => professionalRuntimes,
});
const initialSupervisor = harness.runtimeState(
initialSupervisorOverrides,
) as AgentRuntimeState;
const codePrototypeRuntime = harness.runtimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'code-prototype-progress-session',
runId: 'code-prototype-progress-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
delegationId: 'code-prototype-progress-delegation',
status: 'running',
phase: 'execution',
loopIteration: 2,
currentTask: '修复首版程序问题',
currentAction: '修复角色碰撞与重开逻辑',
planSteps: [
{
index: 0,
title: '修复角色碰撞与重开逻辑',
status: 'in_progress',
},
],
activePlanStepIndex: 0,
updatedAt: 7300,
}) as AgentRuntimeState;
professionalRuntimes = [codePrototypeRuntime];
harness.setRuntimeReader(async () => ({
...harness.runtimeResult(initialSupervisor),
recentEvents: initialSupervisor.recentEvents,
}));
const manifest = createGameCreationAppManifest(
'game-chat-progress-broadcast',
'game-chat-progress-broadcast',
);
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') {
return { ...task, status: 'running' as const };
}
return task;
});
let runtimeUpdateHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
runtime: Record<string, unknown>;
};
}) => void,
) => {
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
}
return harness.listen(eventName, handler);
},
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-progress-broadcast',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const status = await screen.findByLabelText('最新状态');
expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3');
expect(status.textContent).toContain('核对首版试玩诊断');
expect(status.textContent).toContain('1 个专业 Agent 活跃');
const runtimeEventAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(1);
});
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
const progress = await screen.findByLabelText('Supervisor 进度播报');
expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1);
expect(progress.getAttribute('data-runtime-owned')).toBe('true');
expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId);
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
expect(within(progress).queryByText(/第 4 轮/u)).toBeNull();
expect(
within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 1/3'),
).not.toBeNull();
expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull();
expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull();
expect(
within(progress).getByText('程序原型 Agent · 修复角色碰撞与重开逻辑'),
).not.toBeNull();
expect(within(progress).getByText('试玩未通过')).not.toBeNull();
expect(
within(progress).getByText(
'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应',
),
).not.toBeNull();
const delegateDecision = gameChatRuntimeEvent({
runId: supervisorRunId,
eventType: 'action',
phase: 'tool-action',
summary: '调用工具 agent.delegate',
detail: 'code-prototype 根据试玩诊断返工碰撞与重开逻辑',
updatedAt: 7400,
});
const updatedSupervisor = harness.runtimeState({
...initialSupervisorOverrides,
loopIteration: 5,
planRevision: 2,
planSteps: [
{ index: 0, title: '完成基础玩法', status: 'completed' },
{ index: 1, title: '核对首版试玩诊断', status: 'completed' },
{ index: 2, title: '安排程序 Agent 返工', status: 'in_progress' },
],
activePlanStepIndex: 2,
recentEvents: [previewFailure, delegateDecision],
updatedAt: 7500,
}) as AgentRuntimeState;
const updateHandler = runtimeUpdateHandler;
if (!updateHandler) {
throw new Error('missing game creator runtime update listener');
}
act(() => {
updateHandler({
payload: {
projectPath,
agentId: 'project-supervisor',
runId: supervisorRunId,
status: 'running',
phase: 'execution',
runtime: {
...harness.runtimeResult(updatedSupervisor),
recentEvents: updatedSupervisor.recentEvents,
},
},
});
});
await waitFor(() => {
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
expect(within(progress).queryByText(/第 5 轮/u)).toBeNull();
expect(
within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 2/3'),
).not.toBeNull();
expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull();
expect(within(progress).getByText('返工决定')).not.toBeNull();
expect(
within(progress).getByText(
'code-prototype 根据试玩诊断返工碰撞与重开逻辑',
),
).not.toBeNull();
});
expect(screen.getByLabelText('Supervisor 进度播报')).toBe(progress);
expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(2);
});
});
it('renders registered image outcomes in one runtime-owned game-chat Supervisor card', async () => {
const projectPath = '/tmp/game-chat-result-images';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const manifest = createGameCreationAppManifest(
'game-chat-result-images',
'game-chat-result-images',
);
manifest.assets.push(
{
id: 'art-spritesheet',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/art-spritesheet.png',
source: { kind: 'canvas', taskId: 'art-asset-plan' },
},
{
id: 'ui-prototype',
kind: 'ui-prototype',
mediaType: 'image/webp',
localPath: 'assets/ui-prototype.webp',
source: { kind: 'canvas', taskId: 'design-foundation' },
},
{
id: 'audio-manifest',
kind: 'audio',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
source: { kind: 'generated' },
},
{
id: 'invalid-image-path',
kind: 'reference',
mediaType: 'image/png',
localPath: 'assets/../outside.png',
source: { kind: 'generated' },
},
);
expect(collectGameChatResultImages(manifest)).toEqual([
{
key: 'art-spritesheet:assets/art-spritesheet.png',
label: '美术素材图集',
mediaType: 'image/png',
path: 'assets/art-spritesheet.png',
},
{
key: 'ui-prototype:assets/ui-prototype.webp',
label: '界面原型',
mediaType: 'image/webp',
path: 'assets/ui-prototype.webp',
},
]);
const pngDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const webpDataUrl = 'data:image/webp;base64,UklGRgAAAABXRUJQVlA4';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-result-images',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'read_local_project_image_preview') {
const relativePath = String(args?.relativePath ?? '');
const mediaType = relativePath.endsWith('.webp')
? 'image/webp'
: 'image/png';
return {
path: relativePath,
mediaType,
byteLen: 12,
dataUrl: mediaType === 'image/webp' ? webpDataUrl : pngDataUrl,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const { unmount } = render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
fireEvent.click(
await screen.findByRole('button', { name: '运行详情 · 成果 2' }),
);
const imageCard = await screen.findByLabelText('Supervisor 成果图片');
expect(imageCard.getAttribute('data-runtime-owned')).toBe('true');
expect(screen.getAllByLabelText('Supervisor 成果图片')).toHaveLength(1);
expect(within(imageCard).getByText('2/2')).not.toBeNull();
expect(
within(imageCard)
.getByRole('img', {
name: '美术素材图集 · assets/art-spritesheet.png',
})
.getAttribute('src'),
).toBe(pngDataUrl);
expect(
within(imageCard)
.getByRole('img', {
name: '界面原型 · assets/ui-prototype.webp',
})
.getAttribute('src'),
).toBe(webpDataUrl);
const imageReadCalls = invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
);
expect(imageReadCalls).toHaveLength(2);
expect(imageReadCalls[0]?.[1]).toMatchObject({
projectPath,
relativePath: 'assets/art-spritesheet.png',
scopeId: expect.any(String),
requestId: expect.any(String),
});
expect(imageReadCalls[1]?.[1]).toMatchObject({
projectPath,
relativePath: 'assets/ui-prototype.webp',
scopeId: imageReadCalls[0]?.[1]?.scopeId,
requestId: expect.any(String),
});
expect(imageReadCalls[1]?.[1]?.requestId).not.toBe(
imageReadCalls[0]?.[1]?.requestId,
);
fireEvent.click(
within(imageCard).getByRole('button', {
name: '查看 美术素材图集',
}),
);
const viewer = screen.getByRole('dialog', {
name: '美术素材图集 图片查看器',
});
const scale = within(viewer).getByLabelText('图片缩放比例');
expect(scale.textContent).toBe('100%');
fireEvent.click(within(viewer).getByRole('button', { name: '放大图片' }));
expect(scale.textContent).toBe('125%');
const stage = within(viewer).getByLabelText('可缩放拖拽图片区域');
fireEvent.wheel(stage, { deltaY: -1 });
expect(scale.textContent).toBe('150%');
const dispatchPointer = (
type: 'pointerdown' | 'pointermove' | 'pointerup',
clientX: number,
clientY: number,
) => {
const event = new Event(type, { bubbles: true });
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: clientX },
clientY: { value: clientY },
pointerId: { value: 1 },
});
fireEvent(stage, event);
};
dispatchPointer('pointerdown', 100, 80);
dispatchPointer('pointermove', 136, 104);
dispatchPointer('pointerup', 136, 104);
const viewerImage = within(viewer).getByRole('img', {
name: '美术素材图集 · assets/art-spritesheet.png',
});
expect(viewerImage.getAttribute('style')).toContain(
'translate(36px, 24px) scale(1.5)',
);
fireEvent.click(within(viewer).getByRole('button', { name: '复位图片' }));
expect(scale.textContent).toBe('100%');
expect(viewerImage.getAttribute('style')).toContain(
'translate(0px, 0px) scale(1)',
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(
screen.queryByRole('dialog', {
name: '美术素材图集 图片查看器',
}),
).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'append_local_conversation_message',
),
).toHaveLength(0);
unmount();
expect(invoke).toHaveBeenCalledWith(
'cancel_local_project_resource_preview_scope',
{ scopeId: imageReadCalls[0]?.[1]?.scopeId },
);
});
it('persists one terminal game-chat stage record and keeps it in the next round', async () => {
const projectPath = '/tmp/game-chat-stage-record';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const manifest = createGameCreationAppManifest(
'game-chat-stage-record',
'game-chat-stage-record',
);
manifest.tasks = manifest.tasks.map((task) =>
isGameChatStageTask(task.id)
? { ...task, status: 'completed' as const }
: task,
);
manifest.assets.push({
id: 'stage-art',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/art-spritesheet.png',
source: { kind: 'canvas', taskId: 'art-asset-plan' },
});
let runtimeUpdateHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
runtime: Record<string, unknown>;
};
}) => void,
) => {
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
}
return harness.listen(eventName, handler);
},
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-stage-record',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 12,
dataUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, { target: { value: '完成这一轮并保留证据' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toBe(true);
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const firstRunId = String(startCall?.[1]?.runId ?? '');
expect(firstRunId).not.toBe('');
const previewPassed = gameChatRuntimeEvent({
runId: firstRunId,
eventType: 'observation',
summary: 'preview.validateok · 浏览器验证已通过',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 9,
}),
updatedAt: 9100,
});
const rework = gameChatRuntimeEvent({
runId: firstRunId,
eventType: 'action',
summary: '调用工具 agent.delegate',
detail: '根据上一版试玩诊断安排程序 Agent 完成返工',
updatedAt: 9000,
});
const terminalRuntime = harness.runtimeState({
runId: firstRunId,
runProfile: 'autonomous-game-build',
status: 'idle',
phase: 'completed',
loopIteration: 6,
currentAction: '等待下一轮输入',
planSteps: [
{ index: 0, title: '完成返工', status: 'completed' },
{ index: 1, title: '通过试玩', status: 'completed' },
],
activePlanStepIndex: null,
recentEvents: [rework, previewPassed],
updatedAt: 9200,
});
harness.appendSupervisorMessage({
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '第一轮已经完成。',
agentId: 'project-supervisor',
messageId: 'stage-record-final-assistant',
updatedAt: 9200,
});
const updateHandler = runtimeUpdateHandler;
if (!updateHandler) {
throw new Error('missing game creator runtime update listener');
}
act(() => {
updateHandler({
payload: {
projectPath,
agentId: 'project-supervisor',
runId: firstRunId,
status: 'idle',
phase: 'completed',
runtime: {
...harness.runtimeResult(terminalRuntime),
recentEvents: [rework, previewPassed],
},
},
});
});
const stageRecordText = await screen.findByText(
/【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/,
);
const stageRecord = stageRecordText.closest('p');
expect(stageRecord?.className).toContain('game-chat-stage-record');
expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull();
expect(stageRecordText.textContent).toContain('试玩通过:revision 9');
expect(stageRecordText.textContent).toContain(
'返工决定:根据上一版试玩诊断安排程序 Agent 完成返工',
);
expect(stageRecordText.textContent).toContain(
'成果图片:美术素材图集(assets/art-spritesheet.png',
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
).startsWith('【Supervisor 阶段记录】'),
),
).toHaveLength(1);
});
fireEvent.change(composer, { target: { value: '开始下一轮' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(2);
});
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
expect(screen.getByText(/试玩通过:revision 9/)).not.toBeNull();
});
it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => {
const projectPath = '/tmp/game-chat-stage-record-manifest-race';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const pendingManifest = createGameCreationAppManifest(
'game-chat-stage-record-manifest-race',
'game-chat-stage-record-manifest-race',
);
const terminalManifest = createGameCreationAppManifest(
'game-chat-stage-record-manifest-race',
'game-chat-stage-record-manifest-race',
);
terminalManifest.tasks = terminalManifest.tasks.map((task) =>
task.id === 'preview-playtest'
? { ...task, status: 'failed' as const }
: [
'design-director',
'art-director',
'art-asset-plan',
'code-director',
'code-prototype',
'preview-readiness',
].includes(task.id)
? { ...task, status: 'completed' as const }
: task,
);
let manifestReady = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-stage-record-manifest-race',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest: pendingManifest,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return manifestReady ? terminalManifest : pendingManifest;
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const composer = screen.getByRole('textbox') as HTMLTextAreaElement;
const form = composer.form;
if (!form) {
throw new Error('missing game-chat composer form');
}
const stageRecordAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
isGameChatStageRecordMessage(
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
),
),
);
await waitFor(() => {
expect(composer.disabled).toBe(false);
});
fireEvent.change(composer, { target: { value: 'manifest race' } });
fireEvent.submit(form);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toBe(true);
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const runId = String(startCall?.[1]?.runId ?? '');
expect(runId).not.toBe('');
act(() => {
harness.emitRuntime(
harness.runtimeState({
runId,
status: 'failed',
phase: 'failed',
currentAction: 'preview-playtest failed',
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validate failed',
detail: JSON.stringify({
diagnosticsCount: 1,
passed: false,
playtestPassed: false,
revision: 9,
}),
updatedAt: 9100,
}),
],
updatedAt: 9200,
}),
);
});
await waitFor(() => {
expect(stageRecordAppends()).toHaveLength(0);
});
manifestReady = true;
fireEvent.change(composer, { target: { value: '/tasks' } });
fireEvent.submit(form);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'get_local_game_manifest' &&
args?.commandId === 'task.list',
),
).toBe(true);
});
await waitFor(() => {
expect(stageRecordAppends()).toHaveLength(1);
});
const stageRecord = String(
(stageRecordAppends()[0]?.[1] as { message?: { content?: string } })
?.message?.content ?? '',
);
expect(stageRecord).toContain('6/7');
});
it('archives a terminal game-chat run restored during initial hydration exactly once', async () => {
const projectPath = '/tmp/game-chat-stage-record-initial-terminal';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const manifest = createGameCreationAppManifest(
'game-chat-stage-record-initial-terminal',
'game-chat-stage-record-initial-terminal',
);
manifest.tasks = manifest.tasks.map((task) =>
isGameChatStageTask(task.id)
? { ...task, status: 'completed' as const }
: task,
);
const runId = 'game-chat-stage-record-initial-terminal-run';
const terminalRuntime = harness.runtimeState({
runId,
status: 'completed',
phase: 'completed',
currentAction: 'preview complete',
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validateok · 试玩验证已通过',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 11,
}),
updatedAt: 9100,
}),
],
updatedAt: 9200,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_game_creator_agent_runtime') {
return harness.runtimeResult(terminalRuntime);
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const stageRecordAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
isGameChatStageRecordMessage(
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
),
),
);
await waitFor(() => {
expect(stageRecordAppends()).toHaveLength(1);
});
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
// A later refresh/runtime snapshot for the same run must not append again.
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/tasks' },
});
fireEvent.submit(
(screen.getByRole('textbox') as HTMLTextAreaElement).form!,
);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'get_local_game_manifest' &&
args?.commandId === 'task.list',
),
).toBe(true);
});
expect(stageRecordAppends()).toHaveLength(1);
});
it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => {
const projectPath = '/tmp/game-chat-stage-record-restart-hydration';
const runId = 'game-chat-stage-record-restart-run';
const terminalRuntime = gameChatRuntimeState({
sessionId: 'supervisor-session-active',
runId,
status: 'completed',
phase: 'completed',
currentAction: 'preview complete',
updatedAt: 9200,
});
const manifest = createGameCreationAppManifest(
'game-chat-stage-record-restart-hydration',
'game-chat-stage-record-restart-hydration',
);
manifest.tasks = manifest.tasks.map((task) =>
isGameChatStageTask(task.id)
? { ...task, status: 'completed' as const }
: task,
);
const progress = buildGameChatProgressEvidence(
terminalRuntime,
{},
manifest,
);
if (!progress) {
throw new Error('missing terminal game-chat progress fixture');
}
const historicalStageRecord = formatGameChatStageRecord(
terminalRuntime,
progress,
collectGameChatResultImages(manifest),
);
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: terminalRuntime,
projectMessages: [
{
role: 'assistant',
content: historicalStageRecord,
agentId: null,
messageId: 'historical-game-chat-stage-record',
updatedAt: 9201,
},
],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const stageRecordAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
isGameChatStageRecordMessage(
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
),
),
);
await waitFor(() => {
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
});
expect(stageRecordAppends()).toHaveLength(0);
// A terminal runtime event can race the hydration refresh. The historical
// record must remain the sole record and must not be appended again.
act(() => {
harness.emitRuntime(
harness.runtimeState({
sessionId: terminalRuntime.sessionId,
runId,
status: 'completed',
phase: 'completed',
currentAction: 'preview complete',
updatedAt: 9300,
}),
);
});
await waitFor(() => {
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
});
expect(stageRecordAppends()).toHaveLength(0);
});
it('starts and displays the first playable game-chat preview exactly once', async () => {
const projectPath = '/tmp/game-chat-auto-preview';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const pendingManifest = createGameCreationAppManifest(
'game-chat-auto-preview',
'game-chat-auto-preview',
);
const completedManifest = createGameCreationAppManifest(
'game-chat-auto-preview',
'game-chat-auto-preview',
);
completedManifest.tasks = completedManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
let playableVersionReady = false;
let previewStarted = false;
let acceptedRunId = '';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-auto-preview',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4311',
port: 4311,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return playableVersionReady ? completedManifest : pendingManifest;
}
if (command === 'start_local_game_preview') {
expect(args).toEqual({ projectPath, expectedRevision: 1 });
previewStarted = true;
return {
url: 'http://127.0.0.1:4311',
port: 4311,
root: `${projectPath}/game`,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
acceptedRunId = String(args?.runId ?? '');
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, {
target: { value: '生成第一版可玩原型' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
{
projectPath,
sessionId: harness.sessionId,
task: '生成第一版可玩原型',
runId: expect.stringMatching(/^project-supervisor-task-/),
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
},
);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
expect(screen.queryByLabelText('游戏运行')).toBeNull();
expect(within(surface).getByText('预览未启动')).not.toBeNull();
playableVersionReady = true;
harness.setProjectRevision(1);
const firstPlayableRevision = gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunId,
revision: 1,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
status: 'running',
phase: 'verification',
currentTask: '验证首个可玩版本',
currentGoal: '交付首个可玩版本',
currentAction: '可玩版本已经通过验证',
updatedAt: 4000,
}),
);
harness.emitAgentRuntime(firstPlayableRevision);
await Promise.resolve();
});
expect(
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
).not.toBeNull();
const previewFrame = screen.getByTitle(
'game-chat-auto-preview 游戏运行画面',
) as HTMLIFrameElement;
expect(previewFrame.src).toMatch(/^http:\/\/127\.0\.0\.1:4311\//);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
projectPath,
event: 'permission.confirm',
commandId: 'preview.start',
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
});
it('restores an authorized playable game-chat preview after restart without starting it twice', async () => {
const projectPath = '/tmp/game-chat-auto-preview-restart';
const runId = 'game-chat-auto-preview-restart-run';
const revision = 7;
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialProjectRevision: revision,
initialRuntime: {
sessionId: 'supervisor-session-active',
runId,
status: 'completed',
phase: 'completed',
updatedAt: 9200,
},
runtimeMapLoader: async () => [
gameChatPreviewPlaytestRuntime({
parentRunId: runId,
revision,
updatedAt: 9100,
}),
],
});
const manifest = createGameCreationAppManifest(
'game-chat-auto-preview-restart',
'game-chat-auto-preview-restart',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
let previewStarted = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_project_revision') {
return { revision };
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4327',
port: 4327,
root: `${projectPath}/game`,
}
: { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
expect(args).toEqual({ projectPath, expectedRevision: revision });
previewStarted = true;
return {
url: 'http://127.0.0.1:4327',
port: 4327,
root: `${projectPath}/game`,
};
}
return harness.invoke(command, args);
},
);
window.localStorage.setItem(
'genarrative.game-chat.auto-preview-authorization.v2',
JSON.stringify({
afterRevision: 0,
afterValidatedAt: 0,
authorizationId: 'restored-preview-authorization',
projectPath,
runId,
}),
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const renderRelease = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
let rendered = renderRelease();
await waitFor(
() => {
expect(invoke.mock.calls).toContainEqual(
expect.arrayContaining(['start_local_game_preview']),
);
},
{ timeout: 5000 },
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(screen.getByLabelText('游戏运行')).not.toBeNull();
expect(
window.localStorage.getItem(
'genarrative.game-chat.auto-preview-authorization.v2',
),
).toBeNull();
rendered.unmount();
rendered = renderRelease();
await waitFor(() => {
expect(screen.getByLabelText('游戏运行')).not.toBeNull();
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
rendered.unmount();
});
it('refreshes a running game-chat iframe after a later run completes without restarting the preview', async () => {
const projectPath = '/tmp/game-chat-preview-revision';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const playableManifest = createGameCreationAppManifest(
'game-chat-preview-revision',
'game-chat-preview-revision',
);
playableManifest.tasks = playableManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const acceptedRunIds: string[] = [];
let previewStarted = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-preview-revision',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4314',
port: 4314,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return playableManifest;
}
if (command === 'start_local_game_preview') {
expect(args).toEqual({ projectPath, expectedRevision: 1 });
previewStarted = true;
return {
url: 'http://127.0.0.1:4314',
port: 4314,
root: `${projectPath}/game`,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
acceptedRunIds.push(String(args?.runId ?? ''));
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, {
target: { value: '生成第一版可玩原型' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(acceptedRunIds).toHaveLength(1);
});
harness.setProjectRevision(1);
const firstPreviewPassed = gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunIds[0],
revision: 1,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunIds[0],
runProfile: 'autonomous-game-build',
status: 'running',
phase: 'verification',
currentTask: '验证第一版可玩原型',
currentGoal: '交付第一版可玩原型',
currentAction: '第一版已经通过浏览器验证',
updatedAt: 4000,
}),
);
harness.emitAgentRuntime(firstPreviewPassed);
await Promise.resolve();
});
const firstFrame = (await screen.findByTitle(
'game-chat-preview-revision 游戏运行画面',
{},
{ timeout: 3000 },
)) as HTMLIFrameElement;
const firstFrameUrl = firstFrame.src;
expect(acceptedRunIds).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunIds[0],
runProfile: 'autonomous-game-build',
status: 'completed',
phase: 'completed',
currentTask: '第一版已经完成',
currentGoal: '第一版已经完成',
currentAction: '完成交付',
lastResponse: '第一版已经完成',
updatedAt: 5000,
}),
);
await Promise.resolve();
});
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
const previewStatusCallsBeforeRevision = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
).length;
fireEvent.change(composer, {
target: { value: '更新第二版玩法和平衡性' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(acceptedRunIds).toHaveLength(2);
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
).length,
).toBeGreaterThan(previewStatusCallsBeforeRevision);
});
expect(screen.getByTitle('game-chat-preview-revision 游戏运行画面')).toBe(
firstFrame,
);
expect(firstFrame.src).toBe(firstFrameUrl);
harness.setProjectRevision(2);
const secondPreviewPassed = gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunIds[1],
revision: 2,
updatedAt: 7900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunIds[1],
runProfile: 'autonomous-game-build',
status: 'completed',
phase: 'completed',
currentTask: '第二版已经完成',
currentGoal: '第二版已经完成',
currentAction: '完成第二版验证',
lastResponse: '第二版已经完成',
updatedAt: 8000,
}),
);
harness.emitAgentRuntime(secondPreviewPassed);
await Promise.resolve();
});
await waitFor(
() => {
const refreshedFrame = screen.getByTitle(
'game-chat-preview-revision 游戏运行画面',
) as HTMLIFrameElement;
expect(
refreshedFrame !== firstFrame || refreshedFrame.src !== firstFrameUrl,
).toBe(true);
},
{ timeout: 3000 },
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(2);
}, 10000);
it('keeps a same-run steer authorization until a newer revision refreshes the running iframe', async () => {
const projectPath = '/tmp/game-chat-same-run-steer-preview';
const driver = await renderGameChatAutoPreviewDriver({
projectPath,
port: 4381,
startPreview: async (args) => {
expect(args).toEqual({ projectPath, expectedRevision: 1 });
return {
url: 'http://127.0.0.1:4381',
port: 4381,
root: `${projectPath}/game`,
};
},
});
await driver.submit('生成首个可玩版本');
await driver.emitValidation(1, 1000);
const firstFrame = (await screen.findByTitle(
'game-chat-same-run-steer-preview 游戏运行画面',
{},
{ timeout: 3000 },
)) as HTMLIFrameElement;
const firstFrameUrl = firstFrame.src;
await waitFor(() => {
expect(driver.readAuthorization()).toBeNull();
});
await driver.submit('保持同一 Run 并继续调整当前版本');
await waitFor(() => {
expect(driver.readAuthorization()).toMatchObject({
afterRevision: 1,
afterValidatedAt: 1000,
projectPath,
runId: driver.parentRunId,
authorizationId: expect.any(String),
});
});
const steerAuthorizationId = driver.readAuthorization()?.authorizationId;
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 },
);
expect(driver.readAuthorization()?.authorizationId).toBe(
steerAuthorizationId,
);
expect(
screen.getByTitle('game-chat-same-run-steer-preview 游戏运行画面'),
).toBe(firstFrame);
expect(firstFrame.src).toBe(firstFrameUrl);
await driver.emitValidation(2, 2000);
await waitFor(
() => {
const refreshedFrame = screen.getByTitle(
'game-chat-same-run-steer-preview 游戏运行画面',
) as HTMLIFrameElement;
expect(
refreshedFrame !== firstFrame || refreshedFrame.src !== firstFrameUrl,
).toBe(true);
expect(driver.readAuthorization()).toBeNull();
},
{ timeout: 3000 },
);
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'steer_game_creator_agent_runtime_task',
),
).toHaveLength(1);
}, 10000);
it('waits for new validation evidence after a revision-drift start failure before retrying', async () => {
const projectPath = '/tmp/game-chat-preview-revision-drift';
let setProjectRevisionOnDrift: ((revision: number) => void) | null = null;
const driver = await renderGameChatAutoPreviewDriver({
projectPath,
port: 4382,
startPreview: async (args) => {
const expectedRevision = Number(args.expectedRevision);
if (expectedRevision === 1) {
setProjectRevisionOnDrift?.(2);
throw new Error(
'本地游戏项目已在验证后发生变化:expectedRevision=1 · currentRevision=2',
);
}
expect(expectedRevision).toBe(2);
return {
url: 'http://127.0.0.1:4382',
port: 4382,
root: `${projectPath}/game`,
};
},
});
setProjectRevisionOnDrift = driver.harness.setProjectRevision;
await driver.submit('生成后验证 revision 漂移恢复');
await driver.emitValidation(1, 1000);
await waitFor(
() => {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(driver.readAuthorization()).not.toBeNull();
},
{ timeout: 3000 },
);
await act(async () => {
driver.harness.emitAgentRuntime(
gameChatPreviewPlaytestRuntime({
parentRunId: driver.parentRunId,
revision: 1,
updatedAt: 1000,
}),
);
await Promise.resolve();
await Promise.resolve();
});
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(driver.readAuthorization()).not.toBeNull();
await driver.emitValidation(2, 2000);
expect(
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
).not.toBeNull();
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(2);
expect(
driver.invoke.mock.calls
.filter(([command]) => command === 'start_local_game_preview')
.at(-1),
).toEqual([
'start_local_game_preview',
{ projectPath, expectedRevision: 2 },
]);
expect(driver.readAuthorization()).toBeNull();
}, 10000);
it('does not let a deferred policy result clear a replacement authorization', async () => {
await assertNewGameChatAuthorizationSupersedesDeferredAttempt('policy');
}, 10000);
it('atomically stops a stale deferred start without clearing its replacement authorization', async () => {
await assertNewGameChatAuthorizationSupersedesDeferredAttempt('start');
}, 10000);
it('allows a new authorization and later validation to retry the same revision after a terminal attempt', async () => {
const projectPath = '/tmp/game-chat-preview-same-revision-retry';
const driver = await renderGameChatAutoPreviewDriver({
projectPath,
port: 4383,
startPreview: async (args, callIndex) => {
expect(args).toEqual({ projectPath, expectedRevision: 1 });
if (callIndex === 1) {
throw new Error('模拟 preview.start 非瞬时失败');
}
return {
url: 'http://127.0.0.1:4383',
port: 4383,
root: `${projectPath}/game`,
};
},
});
await driver.submit('生成同 revision 首次验证');
await driver.emitValidation(1, 1000);
await waitFor(
() => {
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
expect(driver.readAuthorization()).toBeNull();
},
{ timeout: 3000 },
);
await driver.submit('同一 Run 新授权后重新验证同一 revision');
await waitFor(
() => {
expect(driver.readAuthorization()).toMatchObject({
afterRevision: 1,
afterValidatedAt: 1000,
authorizationId: expect.any(String),
});
},
{ timeout: 3000 },
);
await driver.emitValidation(1, 2000);
expect(
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
).not.toBeNull();
expect(
driver.invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(2);
expect(driver.readAuthorization()).toBeNull();
}, 10000);
it('retries the authorized game-chat preview after a transient project write lock', async () => {
const projectPath = '/tmp/game-chat-auto-preview-lock-retry';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const completedManifest = createGameCreationAppManifest(
'game-chat-auto-preview-lock-retry',
'game-chat-auto-preview-lock-retry',
);
completedManifest.tasks = completedManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
let previewStarted = false;
let startAttempts = 0;
let acceptedRunId = '';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-auto-preview-lock-retry',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4313',
port: 4313,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return completedManifest;
}
if (command === 'start_local_game_preview') {
expect(args).toEqual({ projectPath, expectedRevision: 3 });
startAttempts += 1;
if (startAttempts === 1) {
throw new Error(
`项目正在被其他写操作占用:${projectPath}/.agent/project.lock`,
);
}
previewStarted = true;
return {
url: 'http://127.0.0.1:4313',
port: 4313,
root: `${projectPath}/game`,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
acceptedRunId = String(args?.runId ?? '');
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, {
target: { value: '生成后自动预览并处理瞬时写锁' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
});
harness.setProjectRevision(3);
const playableRevision = gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunId,
revision: 3,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
status: 'running',
phase: 'verification',
updatedAt: 4000,
}),
);
harness.emitAgentRuntime(playableRevision);
await Promise.resolve();
});
expect(
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
).not.toBeNull();
expect(startAttempts).toBe(2);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(2);
});
it('adopts the accepted game-chat run when start initially returns the previous idle state', async () => {
const projectPath = '/tmp/game-chat-accepted-run';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const completedManifest = createGameCreationAppManifest(
'game-chat-accepted-run',
'game-chat-accepted-run',
);
completedManifest.tasks = completedManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const previousIdleRuntime = harness.runtimeState({
runId: 'game-chat-previous-idle',
status: 'idle',
phase: 'idle',
updatedAt: 1000,
});
let acceptedRunId = '';
let postStartRuntimeReads = 0;
let allowCompletion = false;
let previewStarted = false;
const acceptedRunEvent = gameChatRuntimeEvent({
runId: 'placeholder',
summary: '新受理 Run 正在执行',
updatedAt: 4000,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-accepted-run',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return previewStarted
? {
status: 'running',
url: 'http://127.0.0.1:4312',
port: 4312,
root: `${projectPath}/game`,
}
: {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return completedManifest;
}
if (command === 'start_local_game_preview') {
expect(args).toEqual({ projectPath, expectedRevision: 4 });
previewStarted = true;
return {
url: 'http://127.0.0.1:4312',
port: 4312,
root: `${projectPath}/game`,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
acceptedRunId = String(args?.runId ?? '');
return {
...harness.runtimeResult(previousIdleRuntime),
acceptedRunId,
};
}
if (command === 'read_game_creator_agent_runtime' && acceptedRunId) {
postStartRuntimeReads += 1;
const state = harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
status: allowCompletion ? 'completed' : 'running',
phase: allowCompletion ? 'completed' : 'execution',
currentTask: '生成 accepted run 首版游戏',
currentGoal: '完成 accepted run 可玩原型',
currentAction: allowCompletion ? '完成交付' : '执行当前计划',
waitingOn: allowCompletion ? '' : '专业任务收束',
nextStep: allowCompletion ? '' : '继续执行',
lastResponse: allowCompletion ? 'accepted run 已完成' : null,
updatedAt: allowCompletion ? 5000 : 4000,
});
return {
...harness.runtimeResult(state),
recentEvents: [
{
...acceptedRunEvent,
runId: acceptedRunId,
status: allowCompletion ? 'completed' : 'running',
phase: allowCompletion ? 'completed' : 'execution',
summary: allowCompletion
? '新受理 Run 已完成'
: acceptedRunEvent.summary,
updatedAt: allowCompletion ? 5000 : 4000,
},
],
};
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, {
target: { value: '生成并接管真实受理 Run' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
expect(postStartRuntimeReads).toBeGreaterThan(0);
expect(screen.getByLabelText('最新状态').textContent).toContain(
'生成 accepted run 首版游戏',
);
});
expect(screen.queryByText('等待新的运行事件')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
await waitFor(
() => {
expect(
within(screen.getByLabelText('最近运行活动')).getByText(
/新受理 Run 正在执行/,
),
).not.toBeNull();
},
{ timeout: 2500 },
);
harness.setProjectRevision(4);
await act(async () => {
harness.emitAgentRuntime(
gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunId,
revision: 4,
updatedAt: 4100,
}),
);
await Promise.resolve();
});
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
});
const startCallIndex = invoke.mock.calls.findIndex(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const acceptedReadCallIndex = invoke.mock.calls.findIndex(
([command], index) =>
index > startCallIndex && command === 'read_game_creator_agent_runtime',
);
const manifestCallIndex = invoke.mock.calls.findIndex(
([command], index) =>
index > startCallIndex && command === 'get_local_game_manifest',
);
expect(acceptedReadCallIndex).toBeGreaterThan(startCallIndex);
expect(manifestCallIndex).toBeGreaterThan(acceptedReadCallIndex);
allowCompletion = true;
await waitFor(
() => {
expect(screen.getByLabelText('最新状态').textContent).toContain(
'本轮已完成',
);
expect(screen.getByLabelText('最近运行活动').textContent).toContain(
'新受理 Run 已完成',
);
},
{ timeout: 2500 },
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(1);
});
it('honors an explicit preview.start deny after a playable revision is verified', async () => {
const projectPath = '/tmp/game-chat-preview-denied';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const completedManifest = createGameCreationAppManifest(
'game-chat-preview-denied',
'game-chat-preview-denied',
);
completedManifest.tasks = completedManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
let acceptedRunId = '';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-preview-denied',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return {
status: 'stopped',
url: null,
port: null,
root: null,
};
}
if (command === 'get_local_game_manifest') {
return completedManifest;
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: ['preview.start'],
confirmCommands: [],
},
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
acceptedRunId = String(args?.runId ?? '');
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText('项目总控对话内容');
await waitFor(() => {
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
});
fireEvent.change(composer, { target: { value: '生成但不要自动启动' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
});
harness.setProjectRevision(5);
const deniedPlayableRevision = gameChatPreviewPlaytestRuntime({
parentRunId: acceptedRunId,
revision: 5,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
status: 'running',
phase: 'verification',
updatedAt: 4000,
}),
);
harness.emitAgentRuntime(deniedPlayableRevision);
await Promise.resolve();
});
await waitFor(
() => {
expect(
within(surface).getByLabelText('最新状态').textContent,
).toContain('项目权限策略拒绝执行:preview.start');
},
{ timeout: 2500 },
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
expect(screen.queryByLabelText('游戏运行')).toBeNull();
});
it('keeps game-chat full-width before preview and switches to desktop 2:1 or mobile vertical layout', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-chat-shell\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
);
expect(styles).toMatch(
/\.game-chat-shell\.has-preview\s*\{[^}]*grid-template-columns:\s*minmax\(0, 2fr\) minmax\(320px, 1fr\)/s,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.game-chat-shell\.has-preview\s*\{[^}]*grid-template-rows:\s*minmax\(220px, 42dvh\) minmax\(0, 1fr\)[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/,
);
});
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
const projectPath = '/tmp/supervisor-chat-only-game';
const historyMessage = '已持久化的项目总控历史';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: historyMessage,
agentId: 'project-supervisor',
messageId: 'supervisor-chat-only-history',
updatedAt: 2000,
},
],
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const messageList = within(surface).getByLabelText('项目总控消息');
expect(await within(messageList).findByText(historyMessage)).not.toBeNull();
expect(harness.invoke).toHaveBeenCalledWith(
'list_game_creator_agent_sessions',
{
projectPath,
agentId: 'project-supervisor',
},
);
expect(harness.invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath,
agentId: 'project-supervisor',
sessionId: harness.sessionId,
});
expect(
within(surface).getByRole('button', { name: '设置' }),
).not.toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
fireEvent.change(within(surface).getByLabelText('项目总控对话内容'), {
target: { value: '继续完成可玩原型' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
{
projectPath,
sessionId: harness.sessionId,
task: '继续完成可玩原型',
runId: expect.stringMatching(/^project-supervisor-task-/),
runProfile: 'standard',
source: 'project-supervisor-gui',
},
);
});
expect(harness.invoke).not.toHaveBeenCalledWith(
'create_game_creator_agent_session',
expect.anything(),
);
expect(harness.invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
});
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
const projectPath = '/tmp/supervisor-chat-only-draft';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
const renderSupervisorChat = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
renderSupervisorChat();
const firstSurface = await screen.findByLabelText('项目总控 Agent 纯聊天');
await within(firstSurface).findByLabelText('项目总控消息');
fireEvent.change(within(firstSurface).getByLabelText('项目总控对话内容'), {
target: { value: '这条草稿还没有发送' },
});
cleanup();
renderSupervisorChat();
const restoredInput = await screen.findByLabelText('项目总控对话内容');
expect(restoredInput).toHaveProperty('value', '这条草稿还没有发送');
});
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-resume';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const detail = await within(surface).findByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
);
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
const confirmation = detail.closest('.pending-command');
expect(confirmation).not.toBeNull();
fireEvent.click(
within(confirmation as HTMLElement).getByRole('button', {
name: '确认',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath },
);
expect(
within(surface).queryByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
),
).toBeNull();
});
});
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-confirmation';
const runId = 'supervisor-chat-only-confirmation-run';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: {
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-confirm',
actionFingerprint: 'standalone-fingerprint-confirm',
tool: 'file.write',
inputSummary: 'game/index.html',
reason: null,
requestedAt: 3000,
},
},
});
harness.setConfirmRuntime(
harness.runtimeState({
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-reject',
actionFingerprint: 'standalone-fingerprint-reject',
tool: 'command.exec',
inputSummary: 'npm test',
reason: null,
requestedAt: 4000,
},
}),
);
harness.setRejectRuntime(
harness.runtimeState({
runId,
status: 'running',
phase: 'planning',
pendingToolAction: null,
}),
);
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
let pendingAction =
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '确认' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'confirm_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-confirm',
note: '用户已确认待执行工具动作',
},
);
});
pendingAction =
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '拒绝' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'reject_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-reject',
note: '用户已拒绝待执行工具动作',
},
);
});
});
it('answers structured questions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-user-input';
const sessionId = 'supervisor-chat-only-user-input-session';
const runId = 'supervisor-chat-only-user-input-run';
const request = agentRuntimeUserInputRequest({
agentId: 'project-supervisor',
sessionId,
runId,
});
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId,
initialRuntime: {
runId,
status: 'waiting-for-user-input',
phase: 'waiting-for-user-input',
currentTask: '准备首版角色规范图',
currentAction: '等待用户补充关键信息',
waitingOn: '你的澄清回答',
nextStep: '提交全部回答后继续同一 Run',
userInputRequest: request,
updatedAt: 6000,
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const card = await within(surface).findByLabelText('Needs input');
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
expect(
within(card).getByText('首版角色规范图采用哪种美术方向?'),
).not.toBeNull();
expect(within(card).getByText('优先验证轮廓与动作可读性。')).not.toBeNull();
expect(
(
within(surface).getByLabelText(
'项目总控对话内容',
) as HTMLTextAreaElement
).disabled,
).toBe(true);
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'answer_game_creator_agent_runtime_user_input',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: request.actionId,
requestId: request.requestId,
responseId: expect.stringMatching(/^app-user-input-/),
answers: { visual_direction: '像素风' },
},
);
});
await waitFor(() => {
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
});
});
it('opens an existing project into the active Supervisor Session, restores history, then starts and steers the same run', async () => {
const projectPath = '/tmp/launcher-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '已恢复的项目总控历史',
agentId: 'project-supervisor',
messageId: 'launcher-supervisor-history',
updatedAt: 2000,
},
],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
expect(
await within(supervisorSurface).findByText('已恢复的项目总控历史'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('list_game_creator_agent_sessions', {
projectPath,
agentId: 'project-supervisor',
});
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath,
agentId: 'project-supervisor',
sessionId: supervisorHarness.sessionId,
});
expect(screen.getByLabelText('专业 Agent 协作状态')).not.toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
'game-creator-agent-progress',
expect.any(Function),
);
const policyReadCountBeforeChat = invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
).length;
fireEvent.change(screen.getByLabelText('项目需求'), {
target: { value: '先完成正式客户端玩法拆解' },
});
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
{
projectPath,
sessionId: supervisorHarness.sessionId,
task: '先完成正式客户端玩法拆解',
runId: expect.stringMatching(/^project-supervisor-task-/),
runProfile: 'autonomous-game-build',
source: 'project-supervisor-gui',
},
);
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const runId = String(startCall?.[1]?.runId ?? '');
await waitFor(() => {
expect(
(
within(supervisorSurface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
fireEvent.change(screen.getByLabelText('项目需求'), {
target: { value: '补充:优先复用现有素材' },
});
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'steer_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
sessionId: supervisorHarness.sessionId,
runId,
steerId: expect.stringMatching(/^project-supervisor-steer-/),
instruction: '补充:优先复用现有素材',
runProfile: 'autonomous-game-build',
source: 'project-supervisor-gui',
},
);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
),
).toHaveLength(policyReadCountBeforeChat);
});
it('keeps the workbench professional Agent status truthful through polling without Tauri events', async () => {
const projectPath = '/tmp/launcher-runtime-status-game';
const supervisorRunId = 'supervisor-runtime-status-run';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-runtime-status-game',
);
expect(manifest.tasks.every((task) => task.status === 'pending')).toBe(
true,
);
let exposeProfessionalRuntimes = false;
let failProfessionalRuntimeReads = false;
let failProfessionalConversationReads = false;
let designConversationMode: 'initial' | 'updated' = 'initial';
let failProfessionalAction = false;
let failProfessionalRetry = true;
let professionalRuntimeReadCount = 0;
let professionalConversationReadCount = 0;
let releaseProfessionalRetry: (() => void) | null = null;
let professionalRuntimes: Array<Record<string, unknown>> = [];
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: {
runId: supervisorRunId,
status: 'running',
phase: 'waiting-for-delegate-receipts',
currentTask: '协调专业 Agent 完成首版原型',
currentAction: '等待专业 Agent 返回真实 Runtime 状态',
waitingOn: '专业 Agent 回执',
updatedAt: 5000,
},
runtimeMapLoader: async () => {
professionalRuntimeReadCount += 1;
if (failProfessionalRuntimeReads) {
throw new Error('模拟专业 Runtime 瞬时读取失败');
}
return exposeProfessionalRuntimes ? professionalRuntimes : [];
},
});
professionalRuntimes = [
supervisorHarness.runtimeState({
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'design-runtime-status-session',
runId: 'design-runtime-status-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
delegationId: 'design-runtime-status-delegation',
status: 'failed',
phase: 'failed',
currentTask: '拆解首版玩法',
currentAction: '策划 Runtime 已失败',
error:
'agentLlm.design-director 后台 Agent 工具计划调用 LLM 失败:kind=transport fingerprint=internal-error-fingerprint chars=545',
updatedAt: 6100,
}),
supervisorHarness.runtimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'art-runtime-status-session',
runId: 'art-runtime-status-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
delegationId: 'art-runtime-status-delegation',
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
currentTask: '写入美术计划',
currentAction: '等待确认写入美术计划',
waitingOn: '用户确认 file.write',
pendingToolAction: {
actionId: 'art-file-write-action',
actionFingerprint: 'art-file-write-fingerprint',
tool: 'file.write',
inputSummary: 'path=assets/art-plan.md · contentChars=7325',
reason: null,
requestedAt: 6200,
},
updatedAt: 6200,
}),
supervisorHarness.runtimeState({
agentId: 'code-director',
taskId: 'code-director',
sessionId: 'code-runtime-status-session',
runId: 'code-runtime-status-run',
source: 'agent-delegate-retry',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
delegationId: 'code-runtime-status-delegation',
status: 'completed',
phase: 'completed',
currentTask: '完成程序实现拆解',
currentAction: '程序方案已完成',
updatedAt: 6300,
}),
supervisorHarness.runtimeState({
agentId: 'art-polish',
taskId: 'art-polish',
sessionId: 'stale-art-session',
runId: 'stale-art-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: 'stale-supervisor-run',
delegationId: 'stale-art-delegation',
status: 'running',
phase: 'action',
currentTask: '旧父 Run 的美术任务不得显示',
currentAction: '旧父 Run 的状态不得显示',
updatedAt: 9999,
}),
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-runtime-status-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (
command === 'read_local_conversation' &&
args?.agentId === 'code-director'
) {
professionalConversationReadCount += 1;
if (failProfessionalConversationReads) {
throw new Error('模拟专业成果持久对话瞬时读取失败');
}
return {
path: `${projectPath}/.agent/conversations/agents/code-director.jsonl`,
agentId: 'code-director',
sessionId: 'code-runtime-status-session',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '已完成程序实现拆解,并给出可执行的模块边界。',
agentId: 'code-director',
messageId:
'agent-finalization-11111111111111111111111111111111',
updatedAt: 6300,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '新一轮程序任务失败,这不是可交付成果。',
agentId: 'code-director',
messageId: null,
updatedAt: 6400,
},
],
};
}
if (
command === 'read_local_conversation' &&
args?.agentId === 'design-director'
) {
professionalConversationReadCount += 1;
if (failProfessionalConversationReads) {
throw new Error('模拟专业成果持久对话瞬时读取失败');
}
const designFinalization =
designConversationMode === 'updated'
? {
content: '更新后的策划历史成果,替换同 Agent 的旧回执。',
messageId:
'agent-finalization-33333333333333333333333333333333',
updatedAt: 7200,
}
: {
content: '上一轮已经完成并验收的策划成果。',
messageId:
'agent-finalization-22222222222222222222222222222222',
updatedAt: 5900,
};
return {
path: `${projectPath}/.agent/conversations/agents/design-director.jsonl`,
agentId: 'design-director',
sessionId: 'design-runtime-status-session',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: designFinalization.content,
agentId: 'design-director',
messageId: designFinalization.messageId,
updatedAt: designFinalization.updatedAt,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '当前新一轮策划失败,不得覆盖旧成果。',
agentId: 'design-director',
messageId: null,
updatedAt: 6100,
},
],
};
}
if (
command === 'read_local_conversation' &&
args?.agentId === 'art-asset-plan'
) {
professionalConversationReadCount += 1;
if (failProfessionalConversationReads) {
throw new Error('模拟专业成果持久对话瞬时读取失败');
}
return {
path: `${projectPath}/.agent/conversations/agents/art-asset-plan.jsonl`,
agentId: 'art-asset-plan',
sessionId: 'art-history-session',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '历史美术资源计划回执,尚不等于图片资产。',
agentId: 'art-asset-plan',
messageId:
'agent-finalization-44444444444444444444444444444444',
updatedAt: 5800,
},
],
};
}
if (
command === 'read_local_conversation' &&
args?.agentId === 'balance-seed'
) {
professionalConversationReadCount += 1;
if (failProfessionalConversationReads) {
throw new Error('模拟专业成果持久对话瞬时读取失败');
}
return {
path: `${projectPath}/.agent/conversations/agents/balance-seed.jsonl`,
agentId: 'balance-seed',
sessionId: 'balance-history-session',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '历史数值方案回执。',
agentId: 'balance-seed',
messageId:
'agent-finalization-55555555555555555555555555555555',
updatedAt: 5700,
},
],
};
}
if (command === 'confirm_retry_game_creator_agent_runtime_task') {
if (failProfessionalRetry) {
throw new Error(
'agentLlm.design-director retry失败:kind=transport fingerprint=private-retry-fingerprint chars=256',
);
}
await new Promise<void>((resolve) => {
releaseProfessionalRetry = resolve;
});
return supervisorHarness.runtimeResult(
supervisorHarness.runtimeState({
agentId: String(args?.agentId ?? ''),
taskId: String(args?.agentId ?? ''),
sessionId: 'design-runtime-status-session',
runId: String(args?.nextRunId ?? ''),
source: 'agent-delegate-retry',
parentAgentId: 'project-supervisor',
parentRunId: supervisorRunId,
status: 'running',
phase: 'planning',
currentTask: '在当前项目重试策划任务',
currentAction: '重新生成 Agent 工具计划',
updatedAt: 7000,
}),
);
}
if (
failProfessionalAction &&
command === 'confirm_game_creator_agent_runtime_task'
) {
throw new Error(
'agentLlm.art-director 工具确认失败:kind=transport fingerprint=private-action-fingerprint chars=384',
);
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
expect(await screen.findByLabelText('项目总控对话')).not.toBeNull();
await waitFor(() => {
expect(professionalRuntimeReadCount).toBeGreaterThan(0);
});
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
const supervisorStatusPanel = screen.getByLabelText('项目总控 Agent 状态');
supervisorStatusPanel.scrollTop = 120;
const readCountBeforeExpose = professionalRuntimeReadCount;
exposeProfessionalRuntimes = true;
const professionalList = await screen.findByLabelText(
'专业 Agent 实时状态',
{},
{ timeout: 2500 },
);
expect(professionalRuntimeReadCount).toBeGreaterThan(readCountBeforeExpose);
expect(
within(professionalList).getByText('策划 Agent 服务连接失败,请稍后重试'),
).not.toBeNull();
expect(professionalList.textContent).not.toContain('fingerprint');
expect(professionalList.textContent).not.toContain('chars=545');
expect(professionalList.textContent).not.toContain('contentChars');
expect(supervisorStatusPanel.scrollTop).toBe(0);
expect(
within(professionalList).getByText('等待确认后继续工作'),
).not.toBeNull();
expect(within(professionalList).getByText('本轮工作已完成')).not.toBeNull();
expect(
within(professionalList).queryByText('旧父 Run 的状态不得显示'),
).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath,
agentId: 'code-director',
});
});
const codeAgentCard = within(professionalList)
.getByText('程序 Agent')
.closest('article')!;
const resultButton = await within(codeAgentCard).findByRole('button', {
name: '查看成果',
});
fireEvent.click(resultButton);
const resultDialog = await screen.findByRole('dialog', {
name: '程序 Agent 文本回执',
});
expect(resultDialog.textContent).toContain(
'已完成程序实现拆解,并给出可执行的模块边界。',
);
fireEvent.click(within(resultDialog).getByRole('button', { name: '关闭' }));
const documentResources = screen.getByLabelText('文档');
expect(
within(documentResources).getByRole('button', {
name: '打开资源详情:文档 程序 Agent 文本回执',
}),
).not.toBeNull();
const designReceiptCard = within(documentResources).getByRole('button', {
name: '打开资源详情:文档 策划 Agent 文本回执',
});
expect(designReceiptCard).not.toBeNull();
expect(
within(documentResources).getByRole('button', {
name: '打开资源详情:文档 美术资源计划 Agent 文本回执',
}),
).not.toBeNull();
expect(
within(documentResources).getByRole('button', {
name: '打开资源详情:文档 数值 Agent 文本回执',
}),
).not.toBeNull();
expect(documentResources.textContent).not.toContain(
'当前新一轮策划失败,不得覆盖旧成果。',
);
fireEvent.click(designReceiptCard);
const designReceiptFocus = await screen.findByRole('region', {
name: '策划 Agent 文本回执',
});
expect(
within(designReceiptFocus).getByText('历史成果 · 策划 Agent'),
).not.toBeNull();
fireEvent.click(
within(designReceiptFocus).getByRole('button', { name: '收起资源' }),
);
failProfessionalConversationReads = true;
const conversationReadCountBeforeFailure =
professionalConversationReadCount;
professionalRuntimes = professionalRuntimes.map((runtime) =>
runtime.agentId === 'design-director'
? { ...runtime, updatedAt: 7100 }
: runtime,
);
await waitFor(
() => {
expect(professionalConversationReadCount).toBeGreaterThan(
conversationReadCountBeforeFailure,
);
expect(
within(screen.getByLabelText('文档')).getByRole('button', {
name: '打开资源详情:文档 策划 Agent 文本回执',
}),
).not.toBeNull();
expect(
within(screen.getByLabelText('文档')).getByRole('button', {
name: '打开资源详情:文档 程序 Agent 文本回执',
}),
).not.toBeNull();
},
{ timeout: 2500 },
);
failProfessionalConversationReads = false;
designConversationMode = 'updated';
const conversationReadCountBeforeUpdate = professionalConversationReadCount;
professionalRuntimes = professionalRuntimes.map((runtime) =>
runtime.agentId === 'design-director'
? { ...runtime, updatedAt: 7200 }
: runtime,
);
await waitFor(
() => {
expect(professionalConversationReadCount).toBeGreaterThan(
conversationReadCountBeforeUpdate,
);
},
{ timeout: 2500 },
);
const updatedDesignCard = await waitFor(() => {
const card = within(screen.getByLabelText('文档')).getByRole('button', {
name: '打开资源详情:文档 策划 Agent 文本回执',
});
expect(card.getAttribute('data-resource-id')).toContain(
'agent-finalization-33333333333333333333333333333333',
);
return card;
});
fireEvent.click(updatedDesignCard);
const updatedDesignDialog = await screen.findByRole('region', {
name: '策划 Agent 文本回执',
});
expect(
within(updatedDesignDialog).getByText(
'更新后的策划历史成果,替换同 Agent 的旧回执。',
),
).not.toBeNull();
fireEvent.click(
within(updatedDesignDialog).getByRole('button', { name: '收起资源' }),
);
const dock = screen.getByLabelText('子 Agent 状态栏');
await waitFor(() => {
expect(
within(
within(dock).getByRole('article', { name: /策划 Agent/ }),
).getByText('失败'),
).not.toBeNull();
expect(
within(
within(dock).getByRole('article', { name: /美术 Agent/ }),
).getByText('待确认'),
).not.toBeNull();
expect(
within(
within(dock).getByRole('article', { name: /程序 Agent/ }),
).getByText('已完成'),
).not.toBeNull();
});
const artPendingAction =
within(professionalList).getByLabelText('美术 Agent待确认动作');
failProfessionalAction = true;
fireEvent.click(
within(artPendingAction).getByRole('button', { name: '确认' }),
);
expect(
await screen.findByText('专业 Agent 服务连接失败,请稍后重试'),
).not.toBeNull();
expect(screen.queryByText(/private-action-fingerprint/)).toBeNull();
failProfessionalAction = false;
fireEvent.click(
within(artPendingAction).getByRole('button', { name: '确认' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'art-director',
runId: 'art-runtime-status-run',
actionId: 'art-file-write-action',
note: '用户已确认专业 Agent 待执行工具动作',
},
);
});
failProfessionalRuntimeReads = true;
const readCountBeforeFailure = professionalRuntimeReadCount;
await waitFor(
() => {
expect(professionalRuntimeReadCount).toBeGreaterThan(
readCountBeforeFailure,
);
},
{ timeout: 2500 },
);
expect(
within(
within(dock).getByRole('article', { name: /策划 Agent/ }),
).getByText('失败'),
).not.toBeNull();
expect(
within(
within(dock).getByRole('article', { name: /美术 Agent/ }),
).getByText('待确认'),
).not.toBeNull();
expect(
within(
within(dock).getByRole('article', { name: /程序 Agent/ }),
).getByText('已完成'),
).not.toBeNull();
let retryButton = within(professionalList).getByRole('button', {
name: '在当前项目重试',
});
fireEvent.click(retryButton);
expect(
await within(retryButton.closest('article')!).findByText(
'策划 Agent 服务连接失败,请稍后重试',
),
).not.toBeNull();
expect(retryButton.closest('article')?.textContent).not.toContain(
'private-retry-fingerprint',
);
failProfessionalRetry = false;
retryButton = within(professionalList).getByRole('button', {
name: '在当前项目重试',
});
fireEvent.click(retryButton);
const retryingButton = within(professionalList).getByRole('button', {
name: '正在提交重试…',
});
expect(retryingButton.hasAttribute('disabled')).toBe(true);
expect(
within(retryingButton.closest('article')!).getByRole('status')
.textContent,
).toBe('正在提交重试…');
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'design-director',
runId: 'design-runtime-status-run',
nextRunId: expect.stringMatching(/^project-professional-retry-/),
},
);
});
releaseProfessionalRetry?.();
await within(professionalList).findByText(
'重试请求已受理,正在同步新一轮状态',
);
failProfessionalRuntimeReads = false;
exposeProfessionalRuntimes = false;
const readCountBeforeAuthoritativeEmpty = professionalRuntimeReadCount;
await waitFor(
() => {
expect(professionalRuntimeReadCount).toBeGreaterThan(
readCountBeforeAuthoritativeEmpty,
);
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
expect(
within(
within(dock).getByRole('article', { name: /策划 Agent/ }),
).getByText('等待中'),
).not.toBeNull();
expect(
within(screen.getByLabelText('文档')).getByRole('button', {
name: '打开资源详情:文档 策划 Agent 文本回执',
}),
).not.toBeNull();
},
{ timeout: 2500 },
);
}, 10_000);
it('does not render a ready Supervisor stream twice when its assistant is already in restored history', async () => {
const projectPath = '/tmp/launcher-supervisor-finalizing';
const reply = '已经落盘的唯一项目总控回复';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-supervisor-finalizing',
);
const runId = 'supervisor-finalizing-run';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: reply,
agentId: 'project-supervisor',
messageId: 'launcher-supervisor-final-assistant',
updatedAt: 7000,
},
],
initialRuntime: {
runId,
status: 'running',
phase: 'finalizing',
loopIteration: 1,
appliedSteerCursor: 0,
updatedAt: 7000,
},
initialResponseStream: projectSupervisorResponseStream({
runId,
sequence: 8,
accumulatedText: reply,
status: 'ready',
}),
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-supervisor-finalizing',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
expect(await within(supervisorSurface).findByText(reply)).not.toBeNull();
expect(within(supervisorSurface).getAllByText(reply)).toHaveLength(1);
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 实时回复'),
).toBeNull();
});
it('rejects launcher project paths with control characters before Tauri calls', () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: '/tmp/bad\u0007path' },
});
fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]);
expect(screen.getByText('项目目录不能包含控制字符')).not.toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
it('opens a selected Godot project as the active root without initializing the web layout', async () => {
const projectPath = '/tmp/existing-godot-project';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'existing-godot-project',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return projectPath;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: false,
isGodotProject: true,
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'import_local_godot_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开 Godot 项目' }));
const surface = await screen.findByLabelText('项目总控对话');
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
projectPath,
projectId: 'local-project-draft',
name: 'existing-godot-project',
});
expect(
invoke.mock.calls.some(
([command]) => command === 'init_local_game_project',
),
).toBe(false);
const composer = within(surface).getByLabelText('项目需求');
fireEvent.change(composer, { target: { value: '修改玩家移动脚本' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.objectContaining({
projectPath,
runProfile: 'standard',
task: '修改玩家移动脚本',
}),
);
});
});
}
export function registerProjectWorkbenchNavigationTests() {
it('keeps project switching inside the single-window client flow', async () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main');
fireEvent.click(screen.getByRole('button', { name: '切换项目' }));
expect(screen.getByText('请回到首页的项目组切换项目。')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_launcher_window',
);
submitChat('/switch-project');
expect(
screen.getAllByText('请回到首页的项目组切换项目。').length,
).toBeGreaterThanOrEqual(2);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_launcher_window',
);
});
it('opens the current project directory from the main project window', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'chat_with_game_creator_agent') {
return {
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'chat_with_game_creator_role_agent') {
return {
replyText: mockRoleAgentReply(),
};
}
if (command === 'append_local_conversation_message') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'open_local_project_directory') {
return undefined;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:authorized-game');
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
fireEvent.click(revealButtons[revealButtons.length - 1]);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
projectPath: '/tmp/authorized-game',
});
});
expect(await screen.findByText('已打开项目目录。')).not.toBeNull();
submitChat('/open-project');
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'open_local_project_directory',
),
).toHaveLength(2);
});
expect(
screen.getAllByText('已打开项目目录。').length,
).toBeGreaterThanOrEqual(2);
});
it('fills an asset registration draft from recent project files without registering immediately', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_local_project_file') {
throw new Error('missing trace');
}
if (command === 'list_local_project_files') {
return {
projectPath: String(args?.projectPath ?? ''),
files: [
{
path: 'assets/uploads/hero.png',
kind: 'file',
size: 4,
modifiedAt: 1700000001,
},
],
};
}
if (command === 'register_local_asset') {
throw new Error('should only fill the chat draft');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
await act(async () => {
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
});
await screen.findByText('已打开:authorized-game');
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '文件' }));
expect(await screen.findByText(/本地项目文件:/)).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('最近项目文件')).getByRole('button', {
name: '登记资产 assets/uploads/hero.png',
}),
);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText('创作想法')),
);
expect(screen.getByLabelText('创作想法')).toHaveProperty(
'value',
'/asset-register assets/uploads/hero.png image image/png',
);
expect(
screen.queryByText('asset.register · assets/uploads/hero.png'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'register_local_asset',
expect.anything(),
);
});
}
export function registerProjectAgentStatusTests() {
it('shows runtime status and recent tasks in the main agent status list', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const runtimeTask = {
schemaVersion: 'game-creator-agent-runtime-task.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'runtime-design-director-1',
source: 'agent-background-task',
task: '排队补齐世界观拆解',
status: 'pending',
phase: 'queued',
currentAction: '等待当前任务完成',
error: null,
updatedAt: 10,
};
const runtimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'runtime-design-director-1',
source: 'agent-background-task',
status: 'running',
phase: 'planning',
currentTask: '拆解关卡节奏',
currentGoal: '补齐第一关节奏目标',
currentAction: '整理目标和约束',
waitingOn: 'Agent 输出计划或回复',
loopIteration: 2,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [
{
index: 0,
title: '读取项目上下文',
status: 'active',
detail: '正在整理目标和约束',
updatedAt: 11,
},
],
activePlanStepIndex: 0,
observations: ['已创建本轮 Agent Runtime run。'],
taskQueue: {
total: 2,
pending: 1,
running: 1,
completed: 0,
failed: 0,
latestRunId: 'runtime-design-director-1',
updatedAt: 10,
},
allowedTools: ['conversation.read'],
lastResponse: null,
error: null,
updatedAt: 10,
};
const completedRuntimeState = {
...runtimeState,
status: 'idle',
phase: 'completed',
currentAction: '等待下一轮输入',
waitingOn: '开发者下一轮输入',
nextStep: '等待下一轮输入',
taskQueue: {
total: 2,
pending: 1,
running: 0,
completed: 1,
failed: 0,
latestRunId: 'runtime-design-director-1',
updatedAt: 12,
},
lastResponse: '已补齐第一关节奏目标。',
updatedAt: 12,
};
const completedRuntimeTask = {
...runtimeTask,
status: 'completed',
phase: 'completed',
currentAction: '等待下一轮输入',
updatedAt: 12,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [
{
state: runtimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: runtimeState.taskQueue,
recentEvents: [],
recentTasks: [runtimeTask],
},
];
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
let runtimeUpdateHandler:
((event: { payload: Record<string, unknown> }) => void) | null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-agent-progress') {
return () => {};
}
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
};
}
throw new Error(`unexpected listen ${eventName}`);
},
);
window.__TAURI__ = { core: { invoke }, event: { listen } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain(
'Runtimerunning / planning · Loop 2/3 · 整理目标和约束 · 等待 Agent 输出计划或回复 · 下一步 等待 Agent 输出计划或回复 · run runtime-design-director-1',
);
expect(designCard.textContent).toContain('当前目标:补齐第一关节奏目标');
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
expect(designCard.textContent).toContain(
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 1 · waiting 0 · needsInput 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:pending / queued · 排队补齐世界观拆解',
);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(1);
});
await act(async () => {
runtimeUpdateHandler?.({
payload: {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'runtime-design-director-1',
status: 'idle',
phase: 'completed',
runtime: {
state: completedRuntimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: completedRuntimeState.taskQueue,
recentEvents: [],
recentTasks: [completedRuntimeTask],
},
},
});
});
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain(
'Runtimeidle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 0 · waiting 0 · needsInput 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:completed / completed · 排队补齐世界观拆解',
);
});
});
it('lets the developer schedule ready manifest tasks into agent runtimes', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const runtimeTask = {
schemaVersion: 'game-creator-agent-runtime-task.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'ready-design-director-1',
source: 'agent-ready-task-scheduler',
task: '执行 Ready 任务:拆解创作方向',
status: 'running',
phase: 'planning',
currentAction: '准备读取项目上下文',
error: null,
updatedAt: 20,
};
const runtimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'ready-design-director-1',
source: 'agent-ready-task-scheduler',
status: 'running',
phase: 'planning',
currentTask: '执行 Ready 任务:拆解创作方向',
currentGoal: '把 ready 任务投递给对应 Agent',
currentAction: '准备读取项目上下文',
waitingOn: 'Agent 输出计划或回复',
loopIteration: 1,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [],
activePlanStepIndex: null,
observations: [],
taskQueue: {
total: 1,
pending: 0,
running: 1,
completed: 0,
failed: 0,
latestRunId: 'ready-design-director-1',
updatedAt: 20,
},
allowedTools: ['conversation.read'],
lastResponse: null,
error: null,
updatedAt: 20,
};
const runtimeResult = {
state: runtimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: runtimeState.taskQueue,
recentEvents: [],
recentTasks: [runtimeTask],
};
let scheduled = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return scheduled ? [runtimeResult] : [];
}
if (command === 'schedule_game_creator_agent_ready_tasks') {
scheduled = true;
return [runtimeResult];
}
if (command === 'append_local_conversation_message') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const scheduleButton = await screen.findByRole('button', {
name: '调度 Ready',
});
await waitFor(() => {
expect((scheduleButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(scheduleButton);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'schedule_game_creator_agent_ready_tasks' &&
args?.projectPath === '/tmp/authorized-game' &&
args?.limit === 16,
),
).toBe(true);
});
expect(await screen.findAllByText('已调度 1 个 Ready 任务。')).toHaveLength(
2,
);
const agentStatusList = screen.getByLabelText('Agent 状态列表');
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain('Runtimerunning / planning');
expect(designCard.textContent).toContain(
'当前目标:把 ready 任务投递给对应 Agent',
);
expect(designCard.textContent).toContain(
'最近任务:running / planning · 执行 Ready 任务:拆解创作方向',
);
});
});
it('keeps ready task scheduling out of the normal user window', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
),
).toBe(false);
});
it('ignores stale agent conversation reads after switching agents', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseOldConversation: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (args?.agentId === 'design-director') {
return await new Promise((resolve) => {
releaseOldConversation = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '旧 Agent 慢速消息',
agentId: 'design-director',
updatedAt: 1,
},
],
});
});
}
return {
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
agentId: args?.agentId,
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新 Agent 历史消息',
agentId: String(args?.agentId ?? ''),
updatedAt: 2,
},
],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: `/tmp/authorized-game/memory/agents/${String(
args?.taskId ?? '',
)}.md`,
content:
args?.taskId === 'art-director'
? '新 Agent 私有记忆'
: '旧 Agent 私有记忆',
exists: true,
};
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
const designAgentButton = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
await waitFor(() => {
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(designAgentButton);
await waitFor(() => {
expect(releaseOldConversation).not.toBeNull();
});
fireEvent.click(
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
);
expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull();
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
'新 Agent 私有记忆',
);
await act(async () => {
releaseOldConversation?.();
});
expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull();
expect(screen.getByText('新 Agent 历史消息')).not.toBeNull();
expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain(
'旧 Agent 私有记忆',
);
});
it('ignores stale agent conversation saves after switching agents', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseOldSave: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (args?.agentId === 'art-director') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
agentId: 'art-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新 Agent 留存消息',
agentId: 'art-director',
updatedAt: 2,
},
],
};
}
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: `/tmp/authorized-game/memory/agents/${String(
args?.taskId ?? '',
)}.md`,
content: '',
exists: false,
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as {
role: 'user' | 'assistant';
content: string;
agentId: string | null;
};
return await new Promise((resolve) => {
releaseOldSave = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: message.role,
content: message.content,
agentId: message.agentId,
updatedAt: 1,
},
],
});
});
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
const designAgentButton = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
await waitFor(() => {
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(designAgentButton);
const input = await screen.findByLabelText('Agent 对话内容');
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
fireEvent.submit(input.closest('form') as HTMLFormElement);
await screen.findByText('正在保存用户消息');
await waitFor(() => {
expect(releaseOldSave).not.toBeNull();
});
fireEvent.click(
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
);
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
await act(async () => {
releaseOldSave?.();
});
expect(screen.getByText('新 Agent 留存消息')).not.toBeNull();
expect(screen.queryByText('旧 Agent 保存回包')).toBeNull();
expect(screen.queryByText(/已保存 1 条/)).toBeNull();
});
it('ignores stale agent reads after closing the agent dialog', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseConversation: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
return await new Promise((resolve) => {
releaseConversation = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '关闭后不该写入界面状态',
agentId: 'design-director',
updatedAt: 1,
},
],
});
});
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: '关闭后不该读取私有记忆',
exists: true,
};
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
await waitFor(() => {
expect(
(
screen.getByRole('button', {
name: '刷新 Agent',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
await waitFor(() => {
expect(releaseConversation).not.toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(screen.queryByLabelText('Agent 对话')).toBeNull();
await act(async () => {
releaseConversation?.();
});
expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull();
expect(screen.queryByText('conversation.read')).toBeNull();
expect(screen.queryByText('memory.agent.read')).toBeNull();
});
it('updates the open agent dialog when agent status is refreshed', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let runReadCount = 0;
const makeTrace = (withAgentStep: boolean) =>
({
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-open-agent-refresh',
commandId: 'game.generate_draft',
status: 'running',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner',
steps: withAgentStep
? [
{
pass: 1,
agent: 'Planner',
phase: 'plan',
taskId: 'design-director',
group: 'design',
role: 'Director',
status: 'running',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: '刷新后的拆解方向',
toolCalls: [
{
toolId: 'llm.planner.refresh',
status: 'ok',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: '刷新后工具调用',
},
...Array.from({ length: 5 }, (_, index) => ({
toolId: `llm.extra.${index + 1}`,
status: 'ok',
inputPaths: [],
outputPaths: [],
summary: `额外工具调用 ${index + 1}`,
})),
],
},
]
: [],
artifacts: [],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: [],
activeTaskIds: withAgentStep ? ['design-director'] : [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: runReadCount,
}) satisfies GameCreationAgentRunTrace;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: args?.agentId
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: args?.agentId ?? null,
messages: [],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: '',
exists: false,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_project_file') {
runReadCount += 1;
return {
path: String(args?.relativePath ?? ''),
absolutePath: `${String(args?.projectPath ?? '')}/${String(
args?.relativePath ?? '',
)}`,
content: JSON.stringify(makeTrace(runReadCount > 1)),
};
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:authorized-game');
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'暂无最近运行证据',
);
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull();
await waitFor(() => {
expect(screen.getByLabelText('Agent 对话').textContent).toContain(
'刷新后的拆解方向',
);
});
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'in: memory/session.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'out: .agent/spec.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'tool: llm.planner.refresh · ok · 刷新后工具调用',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'还有 1 个工具调用',
);
});
it('confirms before refreshing agents when trace read policy requires it', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-agent-refresh-confirm',
commandId: 'game.generate_draft',
status: 'running',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner',
steps: [],
artifacts: [],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: [],
activeTaskIds: [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: 1,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
return {
path: '.agent/run.latest.json',
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
content: JSON.stringify(trace),
};
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
expect(await screen.findByText('agent.trace_read')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/authorized-game',
relativePath: '.agent/run.latest.json',
commandId: 'agent.trace_read',
});
});
});
it('cancels agent run trace refresh policy confirmation from the panel', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
throw new Error('should wait for trace confirmation');
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
const traceReadCommand = await screen.findByText('agent.trace_read');
fireEvent.click(
within(
traceReadCommand.closest('.pending-command') as HTMLElement,
).getByRole('button', { name: '取消' }),
);
expect(
await screen.findByText('run: 已取消读取 Agent trace'),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
});
}